@dockerforge/core 0.1.3 → 0.2.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/README.md CHANGED
@@ -22,6 +22,9 @@ npm install @dockerforge/core
22
22
 
23
23
  ## Generate
24
24
 
25
+ The supported public API is exported from the package root. Internal files under `src/engine` are
26
+ not part of the compatibility contract.
27
+
25
28
  ```js
26
29
  const core = require('@dockerforge/core');
27
30
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dockerforge/core",
3
- "version": "0.1.3",
3
+ "version": "0.2.0",
4
4
  "description": "DockerForge engine: analyse a local project and generate production-grade Dockerfiles, .dockerignore, and Compose, and lint Dockerfiles. Offline, no network.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Docker Forge",
@@ -14,6 +14,9 @@
14
14
  "url": "https://github.com/Mo-ASayed/DockerForge/issues"
15
15
  },
16
16
  "main": "src/index.js",
17
+ "exports": {
18
+ ".": "./src/index.js"
19
+ },
17
20
  "files": [
18
21
  "src",
19
22
  "LICENSE",
@@ -37,8 +40,8 @@
37
40
  "sarif"
38
41
  ],
39
42
  "dependencies": {
40
- "adm-zip": "^0.5.10",
41
- "fs-extra": "^11.2.0",
42
- "glob": "^10.3.10"
43
+ "adm-zip": "^0.5.18",
44
+ "fs-extra": "^11.3.6",
45
+ "glob": "^13.0.6"
43
46
  }
44
47
  }
@@ -4,12 +4,47 @@
4
4
  const path = require('path');
5
5
  const fs = require('fs-extra');
6
6
  const { STACKS, DEFAULT_VERSIONS, DEFAULT_PORTS, ROOT_CONFIG_FILES } = require('../constants');
7
+ const { UnsupportedStackError } = require('../../errors');
8
+
9
+ // Marker files for popular languages DockerForge does NOT generate for yet. Used only when no
10
+ // supported stack was found, so we can name the language in the error instead of a generic
11
+ // "nothing found". Keep this list ordered most-specific first.
12
+ const UNSUPPORTED_LANGUAGE_MARKERS = [
13
+ { name: 'Ruby', files: ['Gemfile', 'Gemfile.lock', 'config.ru', '.ruby-version'] },
14
+ { name: 'Java', files: ['pom.xml', 'build.gradle', 'build.gradle.kts', 'settings.gradle', 'gradlew'] },
15
+ { name: 'PHP', files: ['composer.json', 'composer.lock', 'artisan'] },
16
+ { name: 'Elixir', files: ['mix.exs'] },
17
+ { name: 'Scala', files: ['build.sbt'] },
18
+ { name: 'Swift', files: ['Package.swift'] },
19
+ { name: 'Dart or Flutter', files: ['pubspec.yaml'] },
20
+ { name: 'Deno', files: ['deno.json', 'deno.jsonc'] },
21
+ ];
22
+
23
+ const SUPPORTED_STACKS_TEXT = 'Node.js, Python, .NET, Go, and Rust';
24
+
25
+ // Project-controlled names (a Cargo crate name, a Go module path, a cmd/ dir) are interpolated
26
+ // into shell-form RUN instructions in the generated Dockerfile. A crafted manifest could smuggle
27
+ // shell metacharacters there and run arbitrary commands at `docker build` time. Restrict such
28
+ // tokens to a safe charset; anything outside it falls back to a default so the output is always
29
+ // a literal, single token. Valid Cargo/Go names already satisfy this, so real projects are
30
+ // unaffected.
31
+ const SAFE_NAME_RE = /^[A-Za-z0-9._-]+$/;
32
+ function safeToken(value, fallback) {
33
+ return typeof value === 'string' && SAFE_NAME_RE.test(value) ? value : fallback;
34
+ }
35
+ // A Go build target is either "." or "./seg/seg" with safe segments only.
36
+ function safeGoBuildTarget(target) {
37
+ if (target === '.') return true;
38
+ if (!target.startsWith('./')) return false;
39
+ return target.slice(2).split('/').every(seg => SAFE_NAME_RE.test(seg));
40
+ }
7
41
 
8
42
  // ── Constants ────────────────────────────────────────────────────────────────
9
43
 
10
44
  // Dirs that are never service roots
11
45
  const SKIP_DIRS = new Set([
12
46
  'node_modules', '.git', '.next', '.nuxt', 'dist', 'build',
47
+ 'target', 'vendor', // rust build output / go vendored deps
13
48
  '__pycache__', '.pytest_cache', 'bin', 'obj', 'coverage',
14
49
  '.venv', 'venv', 'env', '.env', '.idea', '.vscode',
15
50
  'public', 'static', 'assets', 'media', '.cache', 'out', '.output',
@@ -126,6 +161,8 @@ const LOCKFILE_MAP = {
126
161
  [STACKS.NODE]: ['yarn.lock', 'package-lock.json', 'pnpm-lock.yaml'],
127
162
  [STACKS.PYTHON]: [], // pip projects have no lockfile; poetry/pipenv detected via their own manifests
128
163
  [STACKS.DOTNET]: [],
164
+ [STACKS.GO]: [], // go.mod is the manifest; go.sum is optional for detection
165
+ [STACKS.RUST]: [], // Cargo.toml is the manifest; Cargo.lock optional for detection
129
166
  };
130
167
 
131
168
  function hasLockfile(fileNames, stack) {
@@ -133,6 +170,66 @@ function hasLockfile(fileNames, stack) {
133
170
  return required.length === 0 || required.some(lf => fileNames.includes(lf));
134
171
  }
135
172
 
173
+ async function fileContainsGoMain(filePath) {
174
+ try {
175
+ const content = await fs.readFile(filePath, 'utf-8');
176
+ return /^\s*package\s+main\b/m.test(content);
177
+ } catch {
178
+ return false;
179
+ }
180
+ }
181
+
182
+ async function hasRunnableGoEntrypoint(dir, fileNames) {
183
+ for (const fileName of fileNames) {
184
+ if (fileName.endsWith('.go') && await fileContainsGoMain(path.join(dir, fileName))) {
185
+ return true;
186
+ }
187
+ }
188
+
189
+ try {
190
+ const cmdDir = path.join(dir, 'cmd');
191
+ const cmdEntries = await fs.readdir(cmdDir, { withFileTypes: true });
192
+ for (const entry of cmdEntries) {
193
+ if (!entry.isDirectory()) continue;
194
+ const entryDir = path.join(cmdDir, entry.name);
195
+ const entryFiles = await fs.readdir(entryDir, { withFileTypes: true });
196
+ for (const file of entryFiles) {
197
+ if (file.isFile() && file.name.endsWith('.go') && await fileContainsGoMain(path.join(entryDir, file.name))) {
198
+ return true;
199
+ }
200
+ }
201
+ }
202
+ } catch {
203
+ // No cmd/ tree, or unreadable; root-level package main check above is enough.
204
+ }
205
+
206
+ return false;
207
+ }
208
+
209
+ async function hasRunnableRustEntrypoint(dir, cargoText) {
210
+ if (await fs.pathExists(path.join(dir, 'src', 'main.rs'))) return true;
211
+
212
+ try {
213
+ const binDir = path.join(dir, 'src', 'bin');
214
+ const entries = await fs.readdir(binDir, { withFileTypes: true });
215
+ for (const entry of entries) {
216
+ if (entry.isFile() && entry.name.endsWith('.rs')) return true;
217
+ if (entry.isDirectory() && await fs.pathExists(path.join(binDir, entry.name, 'main.rs'))) return true;
218
+ }
219
+ } catch {
220
+ // src/bin is optional.
221
+ }
222
+
223
+ const binSections = [...cargoText.matchAll(/\[\[bin\]\]([\s\S]*?)(?=\n\s*\[|$)/g)];
224
+ for (const section of binSections) {
225
+ const pathMatch = section[1].match(/^\s*path\s*=\s*"([^"]+)"/m);
226
+ if (!pathMatch) return true;
227
+ if (await fs.pathExists(path.join(dir, pathMatch[1]))) return true;
228
+ }
229
+
230
+ return false;
231
+ }
232
+
136
233
  // Returns all service root dirs found in the tree, with their detected stack.
137
234
  // Depth capped at 6 so we don't crawl into infinite nesting.
138
235
  async function findServiceRoots(projectPath) {
@@ -199,6 +296,19 @@ async function findServiceRoots(projectPath) {
199
296
  roots.set(dir, STACKS.DOTNET);
200
297
  }
201
298
 
299
+ if (!roots.has(dir) && fileNames.includes('go.mod') && await hasRunnableGoEntrypoint(dir, fileNames)) {
300
+ roots.set(dir, STACKS.GO);
301
+ }
302
+
303
+ if (!roots.has(dir) && fileNames.includes('Cargo.toml')) {
304
+ let cargoText = '';
305
+ try { cargoText = await fs.readFile(path.join(dir, 'Cargo.toml'), 'utf-8'); }
306
+ catch { /* unreadable manifests are handled later by the analyser */ }
307
+ if (await hasRunnableRustEntrypoint(dir, cargoText)) {
308
+ roots.set(dir, STACKS.RUST);
309
+ }
310
+ }
311
+
202
312
  // Recurse
203
313
  for (const entry of entries) {
204
314
  if (entry.isDirectory() && !SKIP_DIRS.has(entry.name)) {
@@ -209,9 +319,10 @@ async function findServiceRoots(projectPath) {
209
319
 
210
320
  await walk(projectPath, 0);
211
321
 
212
- // If subdirectory services were found, drop any root-level service.
322
+ // If subdirectory services were found, drop any root-level non-compiled service.
213
323
  // A package.json at project root is usually a Vercel/workspace config, not a runnable service.
214
- if (roots.size > 1 && roots.has(projectPath)) {
324
+ // Go/Rust roots are only registered after proving they have an entrypoint, so keep them.
325
+ if (roots.size > 1 && roots.has(projectPath) && ![STACKS.GO, STACKS.RUST].includes(roots.get(projectPath))) {
215
326
  roots.delete(projectPath);
216
327
  }
217
328
 
@@ -333,6 +444,30 @@ async function findWorkspaceSharedConfigs(projectPath, workspacePackageDirs) {
333
444
  return result;
334
445
  }
335
446
 
447
+ // Scans the project root and its immediate subdirectories for marker files of a known-but-
448
+ // unsupported language. Returns the language name (e.g. "Ruby") or null. Shallow on purpose:
449
+ // it only runs when no supported stack was found, just to make the error message specific.
450
+ async function detectUnsupportedLanguage(projectPath) {
451
+ const seen = new Set();
452
+ const collect = async (dir) => {
453
+ try {
454
+ const entries = await fs.readdir(dir, { withFileTypes: true });
455
+ for (const e of entries) if (e.isFile()) seen.add(e.name);
456
+ return entries;
457
+ } catch { return []; }
458
+ };
459
+ const rootEntries = await collect(projectPath);
460
+ for (const e of rootEntries) {
461
+ if (e.isDirectory() && !SKIP_DIRS.has(e.name)) {
462
+ await collect(path.join(projectPath, e.name));
463
+ }
464
+ }
465
+ for (const { name, files } of UNSUPPORTED_LANGUAGE_MARKERS) {
466
+ if (files.some(f => seen.has(f))) return name;
467
+ }
468
+ return null;
469
+ }
470
+
336
471
  // ── Main Entry ───────────────────────────────────────────────────────────────
337
472
 
338
473
  async function analyseProject(projectPath, hints = {}) {
@@ -346,9 +481,18 @@ async function analyseProject(projectPath, hints = {}) {
346
481
  });
347
482
 
348
483
  if (roots.length === 0) {
349
- throw new Error(
350
- 'No supported stack found (Node.js, Python, .NET). ' +
351
- 'Check the URL points at a folder containing source code.'
484
+ const lang = await detectUnsupportedLanguage(projectPath);
485
+ if (lang) {
486
+ throw new UnsupportedStackError(
487
+ `Detected a ${lang} project, which DockerForge does not generate Dockerfiles for yet. ` +
488
+ `Supported stacks: ${SUPPORTED_STACKS_TEXT}. ` +
489
+ `Follow the project for new language support, or hand-write a Dockerfile for now.`
490
+ );
491
+ }
492
+ throw new UnsupportedStackError(
493
+ `No supported stack found. DockerForge supports ${SUPPORTED_STACKS_TEXT}. ` +
494
+ `Point it at a folder containing one of: package.json, requirements.txt / pyproject.toml / setup.py / Pipfile, ` +
495
+ `a .csproj file, go.mod, or Cargo.toml.`
352
496
  );
353
497
  }
354
498
 
@@ -382,6 +526,8 @@ async function analyseProject(projectPath, hints = {}) {
382
526
  if (resolvedStack === STACKS.NODE) analysis = await analyseNode(dir, files, hints, projectPath, isWorkspace);
383
527
  else if (resolvedStack === STACKS.PYTHON) analysis = await analysePython(dir, files, hints);
384
528
  else if (resolvedStack === STACKS.DOTNET) analysis = await analyseDotnet(dir, files, hints);
529
+ else if (resolvedStack === STACKS.GO) analysis = await analyseGo(dir, files, hints);
530
+ else if (resolvedStack === STACKS.RUST) analysis = await analyseRust(dir, files, hints);
385
531
  else continue;
386
532
 
387
533
  // Attach location info relative to project root
@@ -526,6 +672,7 @@ async function analyseNode(projectPath, files, hints, projectRootPath = null, is
526
672
  // Build output directory — detect framework-specific output paths
527
673
  let buildOutputDir = 'dist';
528
674
  let framework = null;
675
+ let nextStandalone = false; // true when Next.js is configured with output: 'standalone'
529
676
  // Root-level dirs referenced by vite config via '../dirname' imports.
530
677
  // Must be copied into build context before `vite build` runs.
531
678
  const rootBuildDeps = [];
@@ -620,14 +767,20 @@ async function analyseNode(projectPath, files, hints, projectRootPath = null, is
620
767
  }
621
768
  }
622
769
  } else if (framework === 'nextjs') {
623
- try {
624
- const configPath = path.join(serviceDir, 'next.config.js');
625
- if (await fs.pathExists(configPath)) {
770
+ // Read whichever next.config.* exists (js/mjs/cjs/ts) for distDir and the standalone flag.
771
+ for (const configFile of ['next.config.js', 'next.config.mjs', 'next.config.cjs', 'next.config.ts']) {
772
+ try {
773
+ const configPath = path.join(serviceDir, configFile);
774
+ if (!(await fs.pathExists(configPath))) continue;
626
775
  const content = await fs.readFile(configPath, 'utf-8');
627
776
  const match = content.match(/distDir\s*:\s*['"]([^'"]+)['"]/);
628
777
  if (match && match[1]) buildOutputDir = match[1];
629
- }
630
- } catch { /* skip on error */ }
778
+ // `output: 'standalone'` makes `next build` emit a self-contained server bundle
779
+ // (.next/standalone with its own minimal node_modules + server.js).
780
+ if (/output\s*:\s*['"]standalone['"]/.test(content)) nextStandalone = true;
781
+ break;
782
+ } catch { /* skip on error */ }
783
+ }
631
784
  } else if (framework === 'astro') {
632
785
  const astroConfigNames = ['astro.config.mjs', 'astro.config.js', 'astro.config.ts'];
633
786
  for (const configFile of astroConfigNames) {
@@ -719,10 +872,26 @@ async function analyseNode(projectPath, files, hints, projectRootPath = null, is
719
872
  catch { /* absent */ }
720
873
  }
721
874
 
875
+ const SOURCE_FILE_CANDIDATES = [
876
+ entryPoint,
877
+ 'index.html',
878
+ 'server.js', 'server.mjs', 'server.cjs', 'server.ts',
879
+ 'index.js', 'index.mjs', 'index.cjs', 'index.ts',
880
+ 'main.js', 'main.mjs', 'main.cjs', 'main.ts',
881
+ 'app.js', 'app.mjs', 'app.cjs', 'app.ts',
882
+ ].filter(Boolean);
883
+ const sourceFiles = [];
884
+ for (const f of [...new Set(SOURCE_FILE_CANDIDATES)]) {
885
+ try { if ((await fs.stat(path.join(projectPath, f))).isFile()) sourceFiles.push(f); }
886
+ catch { /* absent */ }
887
+ }
888
+
722
889
  // Detect framework build/runtime config files that exist, so we copy real filenames
723
890
  // instead of guessing a single hard-coded one. Next.js also reads its config at runtime.
724
891
  const FRAMEWORK_CONFIG_CANDIDATES = [
725
892
  'next.config.js', 'next.config.mjs', 'next.config.cjs', 'next.config.ts',
893
+ 'vite.config.js', 'vite.config.mjs', 'vite.config.cjs', 'vite.config.ts', 'vite.config.mts',
894
+ 'astro.config.js', 'astro.config.mjs', 'astro.config.cjs', 'astro.config.ts',
726
895
  'tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.cjs', 'tailwind.config.mjs',
727
896
  'components.json', 'jsconfig.json',
728
897
  'remix.config.js', 'svelte.config.js', 'nuxt.config.ts', 'nuxt.config.js',
@@ -762,8 +931,10 @@ async function analyseNode(projectPath, files, hints, projectRootPath = null, is
762
931
  hasScopedPackages,
763
932
  siblingDeps, // root-level dirs referenced via require('../xxx') — must be COPYed into runtime image
764
933
  sourceDirs, // conventional source dirs that actually exist on disk (real, not guessed)
934
+ sourceFiles, // top-level source/entry files that actually exist on disk
765
935
  frameworkConfigFiles, // framework build/runtime config files present (next.config, tailwind.config, ...)
766
936
  nextRuntimeConfig, // the next.config.* file Next.js reads at runtime, or null
937
+ nextStandalone, // true when next.config sets output: 'standalone' (self-contained server bundle)
767
938
  assumptions,
768
939
  };
769
940
  }
@@ -831,6 +1002,29 @@ async function findDjangoDirs(projectPath, files) {
831
1002
  return [...dirs].sort();
832
1003
  }
833
1004
 
1005
+ // Top-level Python packages/modules to COPY for non-Django apps (FastAPI/Flask/plain).
1006
+ // Returns { dirs, rootModules }: directories at the project root that contain .py files,
1007
+ // and standalone .py files that live at the project root. This lets the generator copy the
1008
+ // real source tree (e.g. `app/`) instead of guessing a single root entry file — which breaks
1009
+ // the very common package layout (app/main.py with `uvicorn app.main:app`).
1010
+ function findPythonSourceDirs(projectPath, files) {
1011
+ const dirs = new Set();
1012
+ const rootModules = new Set();
1013
+ for (const filePath of files) {
1014
+ if (!filePath.endsWith('.py')) continue;
1015
+ const rel = path.relative(projectPath, filePath).replace(/\\/g, '/');
1016
+ const parts = rel.split('/');
1017
+ if (parts.length === 1) {
1018
+ // Root-level module. manage.py is handled by the Django path, never as a generic module.
1019
+ if (parts[0] !== 'manage.py') rootModules.add(parts[0]);
1020
+ } else {
1021
+ const topDir = parts[0];
1022
+ if (topDir && !topDir.startsWith('.') && !SKIP_DIRS.has(topDir)) dirs.add(topDir);
1023
+ }
1024
+ }
1025
+ return { dirs: [...dirs].sort(), rootModules: [...rootModules].sort() };
1026
+ }
1027
+
834
1028
  async function findDotnetSourceDirs(projectPath) {
835
1029
  const common = [
836
1030
  'Controllers', 'Services', 'Models', 'Pages', 'Views', 'Repositories',
@@ -981,6 +1175,7 @@ async function analysePython(projectPath, files, hints) {
981
1175
  const requirementsHasHashes = detectRequirementsHashes(dependencyText);
982
1176
  let framework = await detectPythonFramework(projectPath, files, dependencyText);
983
1177
  const djangoAppDirs = framework === 'django' ? await findDjangoDirs(projectPath, files) : [];
1178
+ const pythonSource = findPythonSourceDirs(projectPath, files);
984
1179
  let entryPoint = hints.entryPoint;
985
1180
 
986
1181
  if (!entryPoint) {
@@ -1043,6 +1238,8 @@ async function analysePython(projectPath, files, hints) {
1043
1238
  nativeDeps,
1044
1239
  requirementsHasHashes,
1045
1240
  djangoAppDirs,
1241
+ pythonSourceDirs: pythonSource.dirs,
1242
+ pythonRootModules: pythonSource.rootModules,
1046
1243
  port,
1047
1244
  hasBuild: hasNativeDeps,
1048
1245
  assumptions,
@@ -1090,6 +1287,143 @@ async function analyseDotnet(projectPath, files, hints) {
1090
1287
  };
1091
1288
  }
1092
1289
 
1290
+ // ── Go Analysis ──────────────────────────────────────────────────────────────
1291
+
1292
+ async function analyseGo(projectPath, files, hints) {
1293
+ const assumptions = [];
1294
+ let goMod = '';
1295
+ try { goMod = await fs.readFile(path.join(projectPath, 'go.mod'), 'utf-8'); }
1296
+ catch { assumptions.push('Could not read go.mod, using defaults'); }
1297
+
1298
+ // Go toolchain version from the `go 1.xx` directive.
1299
+ let version = hints.runtimeVersion;
1300
+ if (!version) {
1301
+ const m = goMod.match(/^\s*go\s+(\d+\.\d+)/m);
1302
+ version = m ? m[1] : DEFAULT_VERSIONS[STACKS.GO];
1303
+ if (!m) assumptions.push(`Go version not found in go.mod, defaulted to ${version}`);
1304
+ }
1305
+
1306
+ // Module path → default binary name from its last path segment.
1307
+ const moduleMatch = goMod.match(/^\s*module\s+(\S+)/m);
1308
+ const modulePath = moduleMatch ? moduleMatch[1] : null;
1309
+ const rawBinaryName = (modulePath ? modulePath.split('/').pop() : null) || 'app';
1310
+ const binaryName = safeToken(rawBinaryName, 'app');
1311
+ if (binaryName !== rawBinaryName) {
1312
+ assumptions.push(`Module name "${rawBinaryName}" contains unsafe characters; using binary name "app" — set it explicitly.`);
1313
+ }
1314
+
1315
+ // Locate the main package to build. Conventions: ./main.go at root, or ./cmd/<name>/.
1316
+ const topLevel = files.filter(f => path.dirname(f) === projectPath).map(f => path.basename(f));
1317
+ let buildTarget = '.';
1318
+ if (!topLevel.includes('main.go')) {
1319
+ const cmdDirs = new Set();
1320
+ for (const f of files) {
1321
+ const rel = path.relative(projectPath, f).replace(/\\/g, '/');
1322
+ const parts = rel.split('/');
1323
+ if (parts[0] === 'cmd' && parts.length >= 3 && parts[parts.length - 1].endsWith('.go')) {
1324
+ cmdDirs.add(`./cmd/${parts[1]}`);
1325
+ }
1326
+ }
1327
+ const cmds = [...cmdDirs];
1328
+ if (cmds.length === 1) {
1329
+ buildTarget = cmds[0];
1330
+ } else if (cmds.length > 1) {
1331
+ buildTarget = cmds[0];
1332
+ assumptions.push(`Multiple cmd/ entrypoints found (${cmds.join(', ')}); building ${cmds[0]} — pass a hint to choose another`);
1333
+ } else {
1334
+ assumptions.push('No main.go at root or under cmd/ — defaulted build target to "." ; update it if your main package is elsewhere');
1335
+ }
1336
+ }
1337
+
1338
+ // Explicit source copies (we never emit `COPY . .`): top-level dirs that contain .go files,
1339
+ // plus whether there are .go files at the repo root.
1340
+ const goSourceDirs = new Set();
1341
+ let hasRootGoFiles = false;
1342
+ for (const f of files) {
1343
+ if (!f.endsWith('.go')) continue;
1344
+ const rel = path.relative(projectPath, f).replace(/\\/g, '/');
1345
+ const parts = rel.split('/');
1346
+ if (parts.length === 1) hasRootGoFiles = true;
1347
+ else if (!SKIP_DIRS.has(parts[0])) goSourceDirs.add(parts[0]);
1348
+ }
1349
+
1350
+ // Guard the build target too — it lands in `go build ... <target>`.
1351
+ if (!safeGoBuildTarget(buildTarget)) {
1352
+ assumptions.push(`Build target "${buildTarget}" contains unsafe characters; defaulted to "." — set it explicitly.`);
1353
+ buildTarget = '.';
1354
+ }
1355
+
1356
+ const port = hints.port || DEFAULT_PORTS[STACKS.GO];
1357
+ if (!hints.port) assumptions.push(`Port not detected, defaulted to ${port}`);
1358
+
1359
+ return {
1360
+ stack: STACKS.GO,
1361
+ role: 'backend',
1362
+ version,
1363
+ modulePath,
1364
+ binaryName,
1365
+ buildTarget,
1366
+ hasGoSum: files.some(f => path.basename(f) === 'go.sum'),
1367
+ goSourceDirs: [...goSourceDirs].sort(),
1368
+ hasRootGoFiles,
1369
+ cgo: false,
1370
+ port,
1371
+ hasBuild: true,
1372
+ assumptions,
1373
+ };
1374
+ }
1375
+
1376
+ // ── Rust Analysis ────────────────────────────────────────────────────────────
1377
+
1378
+ async function analyseRust(projectPath, files, hints) {
1379
+ const assumptions = [];
1380
+ let cargo = '';
1381
+ try { cargo = await fs.readFile(path.join(projectPath, 'Cargo.toml'), 'utf-8'); }
1382
+ catch { assumptions.push('Could not read Cargo.toml, using defaults'); }
1383
+
1384
+ // Binary name: an explicit [[bin]] name wins, else the [package] name.
1385
+ let binaryName = null;
1386
+ const binMatch = cargo.match(/\[\[bin\]\][\s\S]*?name\s*=\s*"([^"]+)"/);
1387
+ if (binMatch) binaryName = binMatch[1];
1388
+ if (!binaryName) {
1389
+ const pkgMatch = cargo.match(/\[package\][\s\S]*?name\s*=\s*"([^"]+)"/);
1390
+ binaryName = pkgMatch ? pkgMatch[1] : null;
1391
+ }
1392
+ if (!binaryName) { binaryName = 'app'; assumptions.push('Crate name not found in Cargo.toml, defaulted binary to "app"'); }
1393
+ const rawBinaryName = binaryName;
1394
+ binaryName = safeToken(binaryName, 'app');
1395
+ if (binaryName !== rawBinaryName) {
1396
+ assumptions.push(`Crate name "${rawBinaryName}" contains unsafe characters; using binary name "app" — set it explicitly.`);
1397
+ }
1398
+
1399
+ // Rust toolchain: rust-version in Cargo.toml if present, else default.
1400
+ let version = hints.runtimeVersion;
1401
+ if (!version) {
1402
+ const rv = cargo.match(/rust-version\s*=\s*"([0-9.]+)"/);
1403
+ version = rv ? rv[1] : DEFAULT_VERSIONS[STACKS.RUST];
1404
+ if (!rv) assumptions.push(`rust-version not set in Cargo.toml, defaulted toolchain to ${version}`);
1405
+ }
1406
+
1407
+ const hasLock = files.some(f => path.basename(f) === 'Cargo.lock');
1408
+ if (!hasLock) assumptions.push('No Cargo.lock committed — builds are not fully reproducible; run `cargo generate-lockfile` and commit it');
1409
+ const hasBuildRs = files.some(f => path.basename(f) === 'build.rs');
1410
+
1411
+ const port = hints.port || DEFAULT_PORTS[STACKS.RUST];
1412
+ if (!hints.port) assumptions.push(`Port not detected, defaulted to ${port}`);
1413
+
1414
+ return {
1415
+ stack: STACKS.RUST,
1416
+ role: 'backend',
1417
+ version,
1418
+ binaryName,
1419
+ hasLock,
1420
+ hasBuildRs,
1421
+ port,
1422
+ hasBuild: true,
1423
+ assumptions,
1424
+ };
1425
+ }
1426
+
1093
1427
  // ── File List Helper ─────────────────────────────────────────────────────────
1094
1428
 
1095
1429
  const { globSync } = require('glob');
@@ -1149,6 +1483,7 @@ function getFileList(projectPath) {
1149
1483
  absolute: true,
1150
1484
  ignore: [
1151
1485
  '**/node_modules/**', '**/.git/**', '**/bin/**', '**/obj/**',
1486
+ '**/target/**', '**/vendor/**',
1152
1487
  '**/__tests__/**', '**/__mocks__/**', '**/test/**', '**/tests/**',
1153
1488
  '**/fixtures/**', '**/spec/**', '**/specs/**',
1154
1489
  '**/examples/**', '**/example/**', '**/samples/**', '**/sample/**',
@@ -5,12 +5,16 @@ const STACKS = {
5
5
  NODE: 'node',
6
6
  PYTHON: 'python',
7
7
  DOTNET: 'dotnet',
8
+ GO: 'go',
9
+ RUST: 'rust',
8
10
  };
9
11
 
10
12
  const DETECTION_FILES = {
11
13
  [STACKS.NODE]: ['package.json'],
12
14
  [STACKS.PYTHON]: ['requirements.txt', 'pyproject.toml', 'setup.py', 'Pipfile'],
13
15
  [STACKS.DOTNET]: ['.csproj'], // matched by extension
16
+ [STACKS.GO]: ['go.mod'],
17
+ [STACKS.RUST]: ['Cargo.toml'],
14
18
  };
15
19
 
16
20
  const BASE_IMAGES = {
@@ -20,18 +24,32 @@ const BASE_IMAGES = {
20
24
  runtime: (version) => `mcr.microsoft.com/dotnet/aspnet:${version}`,
21
25
  sdk: (version) => `mcr.microsoft.com/dotnet/sdk:${version}`,
22
26
  },
27
+ // Go and Rust compile to a static-ish binary; build on the toolchain image,
28
+ // ship on a tiny runtime. Go uses alpine; Rust links against glibc so uses debian-slim.
29
+ [STACKS.GO]: {
30
+ build: (version) => `golang:${version}-alpine3.21`,
31
+ runtime: () => `alpine:3.21`,
32
+ },
33
+ [STACKS.RUST]: {
34
+ build: (version) => `rust:${version}-slim-bookworm`,
35
+ runtime: () => `debian:bookworm-slim`,
36
+ },
23
37
  };
24
38
 
25
39
  const DEFAULT_VERSIONS = {
26
40
  [STACKS.NODE]: '20',
27
41
  [STACKS.PYTHON]: '3.12',
28
42
  [STACKS.DOTNET]: '8.0',
43
+ [STACKS.GO]: '1.23',
44
+ [STACKS.RUST]: '1.83',
29
45
  };
30
46
 
31
47
  const DEFAULT_PORTS = {
32
48
  [STACKS.NODE]: 3000,
33
49
  [STACKS.PYTHON]: 8000,
34
50
  [STACKS.DOTNET]: 8080,
51
+ [STACKS.GO]: 8080,
52
+ [STACKS.RUST]: 8080,
35
53
  };
36
54
 
37
55
  const IGNORED_DIRS = [
@@ -8,6 +8,8 @@ function buildExplanation(analysis, result, securityNotes) {
8
8
  [STACKS.NODE]: 'Node.js',
9
9
  [STACKS.PYTHON]: 'Python',
10
10
  [STACKS.DOTNET]: '.NET',
11
+ [STACKS.GO]: 'Go',
12
+ [STACKS.RUST]: 'Rust',
11
13
  };
12
14
 
13
15
  const lines = [];
@@ -51,6 +53,20 @@ function buildExplanation(analysis, result, securityNotes) {
51
53
  lines.push(`**Multi-stage build:** Always used for .NET. The SDK is never shipped to production.`);
52
54
  }
53
55
 
56
+ if (analysis.stack === STACKS.GO) {
57
+ lines.push(
58
+ `**Base images:** build on \`golang:${analysis.version}-alpine\`, ship the compiled binary on \`alpine\`. CGO is disabled so the binary is static — the final image carries no Go toolchain.`
59
+ );
60
+ lines.push(`**Build strategy:** Multi-stage. Only the built binary reaches the runtime image.`);
61
+ }
62
+
63
+ if (analysis.stack === STACKS.RUST) {
64
+ lines.push(
65
+ `**Base images:** build on \`rust:${analysis.version}-slim-bookworm\`, ship the release binary on \`debian:bookworm-slim\` (Rust links glibc by default).`
66
+ );
67
+ lines.push(`**Build strategy:** Multi-stage. Only the release binary reaches the runtime image.`);
68
+ }
69
+
54
70
  // Port
55
71
  lines.push(`**Port:** \`${analysis.port}\` exposed`);
56
72
 
@@ -151,25 +151,42 @@ function nodePruneLine(packageManager, cwd = '') {
151
151
  return `RUN ${prefix}npm prune --omit=dev`;
152
152
  }
153
153
 
154
- function nodeSourceCopyBlock(a, configCopyLine = '', frontendAssetCopyLine = '') {
154
+ function detectedNodeSourceCopyBlock(a) {
155
+ const lines = [];
156
+ const sourceFiles = Array.isArray(a.sourceFiles) ? a.sourceFiles : [];
157
+ const sourceDirs = Array.isArray(a.sourceDirs) ? a.sourceDirs : [];
158
+ const dirSet = new Set(sourceDirs);
159
+
160
+ // Skip a source file that already lives inside a copied source dir — otherwise we emit a
161
+ // redundant (and mis-located) `COPY src/server.js ./` alongside `COPY src/ ./src/`, which
162
+ // drops the entry file at /app/server.js where nothing reads it.
163
+ for (const f of sourceFiles) {
164
+ const top = f.split('/')[0];
165
+ if (f.includes('/') && dirSet.has(top)) continue;
166
+ lines.push(`COPY ${f} ./`);
167
+ }
168
+ for (const d of sourceDirs) lines.push(`COPY ${d}/ ./${d}/`);
169
+
170
+ if (lines.length === 0) {
171
+ lines.push('# No conventional source files were detected; add COPY lines for your application code.');
172
+ }
173
+
174
+ return lines.join('\n');
175
+ }
176
+
177
+ function nodeSourceCopyBlock(a, configCopyLine = '') {
155
178
  const blocks = [];
156
179
  if (configCopyLine) blocks.push(configCopyLine.trimEnd());
157
180
  if (a.framework === 'nextjs') {
158
- blocks.push('COPY app/ ./app/');
159
- blocks.push('COPY pages/ ./pages/');
160
- blocks.push('COPY components/ ./components/');
161
- blocks.push('COPY public/ ./public/');
181
+ blocks.push(detectedNodeSourceCopyBlock(a));
162
182
  } else if (a.framework === 'remix') {
163
- blocks.push('COPY app/ ./app/');
164
- blocks.push('COPY public/ ./public/');
183
+ blocks.push(detectedNodeSourceCopyBlock(a));
165
184
  } else if (['astro', 'vite', 'cra', 'sveltekit'].includes(a.framework)) {
166
- if (frontendAssetCopyLine) blocks.push(frontendAssetCopyLine.trimEnd());
167
- blocks.push('COPY src/ ./src/');
185
+ blocks.push(detectedNodeSourceCopyBlock(a));
168
186
  } else if (a.framework === 'nestjs') {
169
- blocks.push('COPY src/ ./src/');
187
+ blocks.push(detectedNodeSourceCopyBlock(a));
170
188
  } else {
171
- blocks.push('# Update these COPY lines if your source is not under src/.');
172
- blocks.push('COPY src/ ./src/');
189
+ blocks.push(detectedNodeSourceCopyBlock(a));
173
190
  }
174
191
  return blocks.join('\n');
175
192
  }
@@ -348,6 +365,8 @@ function generateSingleRoot(a, envVars = [], rootConfigFiles = []) {
348
365
  if (a.stack === STACKS.NODE) return generateNodeRoot(a, envVars, rootConfigFiles);
349
366
  if (a.stack === STACKS.PYTHON) return generatePythonRoot(a, rootConfigFiles);
350
367
  if (a.stack === STACKS.DOTNET) return generateDotnetRoot(a);
368
+ if (a.stack === STACKS.GO) return generateGoRoot(a, '.');
369
+ if (a.stack === STACKS.RUST) return generateRustRoot(a, '.');
351
370
  throw new Error(`No template for stack: ${a.stack}`);
352
371
  }
353
372
 
@@ -359,6 +378,8 @@ function generateSingleSub(a, sharedDirs, staticAssets, rootConfigFiles = [], en
359
378
  if (a.stack === STACKS.NODE) return generateNodeSub(a, dir, sharedDirs, staticAssets, rootConfigFiles, envVars, workspacePackageDirs, workspaceSharedConfigs);
360
379
  if (a.stack === STACKS.PYTHON) return generatePythonSub(a, dir, sharedDirs, staticAssets);
361
380
  if (a.stack === STACKS.DOTNET) return generateDotnetSub(a, dir, sharedDirs);
381
+ if (a.stack === STACKS.GO) return generateGoRoot(a, dir);
382
+ if (a.stack === STACKS.RUST) return generateRustRoot(a, dir);
362
383
  throw new Error(`No template for stack: ${a.stack}`);
363
384
  }
364
385
 
@@ -681,9 +702,11 @@ function generateNodeRoot(a, envVars = [], rootConfigFiles = []) {
681
702
  const baseImage = BASE_IMAGES[STACKS.NODE](a.version);
682
703
  const improvements = [];
683
704
  const startCmdJson = JSON.stringify(runtimeStartCmd(a));
684
- const extraConfigs = rootConfigFiles.filter(f => !['package.json', a.lockFile].includes(f));
705
+ const extraConfigs = [...new Set([
706
+ ...rootConfigFiles,
707
+ ...(a.frameworkConfigFiles || []),
708
+ ])].filter(f => !['package.json', a.lockFile].includes(f));
685
709
  const configCopyLine = extraConfigs.length > 0 ? `COPY ${extraConfigs.join(' ')} ./\n` : '';
686
- const frontendAssetCopyLine = (a.role === 'frontend' || a.framework === 'cra') ? 'COPY public/ ./public/\n' : '';
687
710
 
688
711
  let dockerfile;
689
712
 
@@ -691,7 +714,7 @@ function generateNodeRoot(a, envVars = [], rootConfigFiles = []) {
691
714
  const buildInstall = nodeInstallLine(a.packageManager, false, { hasScopedPackages: a.hasScopedPackages });
692
715
  const buildPrefix = buildCommandPrefix(a);
693
716
  const buildOut = a.buildOutputDir || 'dist';
694
- const sourceCopyBlock = nodeSourceCopyBlock(a, configCopyLine, frontendAssetCopyLine);
717
+ const sourceCopyBlock = nodeSourceCopyBlock(a, configCopyLine);
695
718
 
696
719
  // Next.js (and other SSR frameworks): a Node server, NOT a static site.
697
720
  // Build with `next build`, then run `next start` on a lean Node runtime. Serving the
@@ -699,6 +722,57 @@ function generateNodeRoot(a, envVars = [], rootConfigFiles = []) {
699
722
  if (a.framework === 'nextjs') {
700
723
  const nextSrcCopy = nextSourceCopyBlock(a, rootConfigFiles);
701
724
  const hasPublic = Array.isArray(a.sourceDirs) && a.sourceDirs.includes('public');
725
+
726
+ // ── output: 'standalone' — smallest, self-contained server bundle ──
727
+ // `next build` emits .next/standalone (server.js + its own minimal node_modules) plus
728
+ // .next/static. The runtime copies just those — no install, no full node_modules.
729
+ if (a.nextStandalone) {
730
+ const publicCopyStandalone = hasPublic
731
+ ? `COPY --from=builder --chown=node:node /app/public ./public\n` : '';
732
+ dockerfile = `
733
+ # ─── Stage 1: Build ───────────────────────────────────────
734
+ ${builderFrom(baseImage, 'builder')}
735
+
736
+ WORKDIR /app
737
+
738
+ ENV NEXT_TELEMETRY_DISABLED=1
739
+
740
+ COPY ${a.lockFile} package.json ./
741
+ ${buildInstall}
742
+
743
+ # Copy source and build-time config
744
+ ${nextSrcCopy}
745
+ RUN ${buildPrefix}${a.buildCommand}
746
+
747
+ # ─── Stage 2: Runtime ─────────────────────────────────────
748
+ FROM ${baseImage}
749
+
750
+ WORKDIR /app
751
+
752
+ ${buildEnvBlock(envVars)}
753
+ ENV NEXT_TELEMETRY_DISABLED=1
754
+ ENV PORT=${a.port}
755
+ ENV HOSTNAME=0.0.0.0
756
+
757
+ # Self-contained server bundle from output: 'standalone' — no node_modules install needed
758
+ ${publicCopyStandalone}COPY --from=builder --chown=node:node /app/${buildOut}/standalone ./
759
+ COPY --from=builder --chown=node:node /app/${buildOut}/static ./${buildOut}/static
760
+
761
+ USER node
762
+
763
+ ${nodeStopSignalBlock()}
764
+ EXPOSE ${a.port}
765
+ ${simpleHttpHealthcheck(a.port)}
766
+ CMD ["node", "server.js"]`.trim();
767
+
768
+ improvements.push('Next.js standalone build detected (output: "standalone"): shipped as a self-contained server bundle (.next/standalone) — no node_modules in the runtime image, smallest output.');
769
+ if (a.hasScopedPackages) {
770
+ improvements.push('Scoped packages detected (may include a private registry): the build install keeps a secret mount — build with `docker build --secret id=npmrc,src=.npmrc .` and a .npmrc that authenticates your scope.');
771
+ }
772
+ improvements.push('HEALTHCHECK probes /health, which Next.js does not expose by default — add a health route or point the healthcheck at an existing path.');
773
+ return { dockerfile, dockerignore: nodeDockerignore(), improvements };
774
+ }
775
+
702
776
  const publicCopy = hasPublic ? `COPY --from=builder /app/public ./public\n` : '';
703
777
  const runtimeConfigCopy = a.nextRuntimeConfig
704
778
  ? `COPY --from=builder /app/${a.nextRuntimeConfig} ./${a.nextRuntimeConfig}\n`
@@ -751,7 +825,7 @@ ${simpleHttpHealthcheck(a.port)}
751
825
  CMD ${startCmdJson}`.trim();
752
826
 
753
827
  improvements.push('Next.js is built and run as a Node server (next start) so SSR and API routes work — a generic static server image would break them.');
754
- improvements.push('Production node_modules are copied from the build stage (deps fetched once). For an even smaller image, set `output: "standalone"` in next.config and copy .next/standalone + .next/static.');
828
+ improvements.push('Production node_modules are copied from the build stage (deps fetched once). For an even smaller image, set `output: "standalone"` in next.config DockerForge will then emit the self-contained standalone bundle automatically.');
755
829
  improvements.push('Only known framework config files are auto-copied — verify any project-specific build config is also COPYed (e.g. sentry.*.config.ts, next-i18next.config).');
756
830
  if (a.hasScopedPackages) {
757
831
  improvements.push('Scoped packages detected (may include a private registry): the install keeps a secret mount — build with `docker build --secret id=npmrc,src=.npmrc .` and a .npmrc that authenticates your scope.');
@@ -845,14 +919,12 @@ CMD ${startCmdJson}`.trim();
845
919
 
846
920
  improvements.push('HEALTHCHECK probes /health; add a /health endpoint returning 2xx if your app does not already expose one');
847
921
  if (!a.hasBuild) improvements.push('If you add a build step later, use multi-stage builds to keep image small');
848
- if (a.framework === null) {
849
- improvements.push('WARNING: Unknown Node backend source layout - verify your source is not under src/ before using the generated COPY src/ line.');
922
+ if ((a.sourceDirs || []).length === 0 && (a.sourceFiles || []).length === 0) {
923
+ improvements.push('WARNING: No conventional Node source files or directories were detected; add explicit COPY lines for your application code before building.');
850
924
  }
851
925
  if (a.hasScopedPackages) {
852
926
  improvements.push('Scoped packages detected: pass private registry credentials with docker build --secret id=npmrc,src=.npmrc');
853
927
  }
854
- 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.');
855
-
856
928
  improvements.push('Add a SIGTERM handler: process.on(\'SIGTERM\', () => server.close())');
857
929
 
858
930
  return { dockerfile, dockerignore: nodeDockerignore(), improvements };
@@ -1106,10 +1178,19 @@ function generatePythonRoot(a, rootConfigFiles = []) {
1106
1178
 
1107
1179
  // Explicit source copy — COPY . . is banned.
1108
1180
  // Django: manage.py lives at root; user must add app dirs themselves.
1109
- // FastAPI/Flask: copy the detected entry point.
1181
+ // FastAPI/Flask/plain: copy the real top-level packages and root modules detected by the
1182
+ // analyser. Copying just the entry file breaks the standard package layout (e.g. app/main.py
1183
+ // started as `uvicorn app.main:app`) — the `app/` package must be present in the image.
1184
+ const pythonSrcLines = [
1185
+ ...(a.pythonRootModules || []).map(m => `COPY ${m} ./`),
1186
+ ...(a.pythonSourceDirs || []).map(d => `COPY ${d}/ ./${d}/`),
1187
+ ];
1188
+ const nonDjangoSourceCopy = pythonSrcLines.length > 0
1189
+ ? pythonSrcLines.join('\n')
1190
+ : `COPY ${a.entryPoint} ./`;
1110
1191
  const sourceCopyBlock = a.framework === 'django'
1111
1192
  ? `COPY manage.py ./\n# TODO: add your Django app directories below, e.g.:\n# COPY myapp/ ./myapp/`
1112
- : `COPY ${a.entryPoint} ./`;
1193
+ : nonDjangoSourceCopy;
1113
1194
 
1114
1195
  const detectedDjangoCopyBlock = a.framework === 'django' && (a.djangoAppDirs || []).length > 0
1115
1196
  ? `COPY manage.py ./\n${a.djangoAppDirs.map(d => `COPY ${d}/ ./${d}/`).join('\n')}`
@@ -1310,6 +1391,112 @@ ENTRYPOINT ["dotnet", "${a.projectName}.dll"]`.trim();
1310
1391
  return { dockerfile, dockerignore: dotnetDockerignore(), improvements: [] };
1311
1392
  }
1312
1393
 
1394
+ // ── Go ───────────────────────────────────────────────────────────────────────
1395
+ // Compile a static binary on the toolchain image, ship it on a tiny alpine runtime.
1396
+ // Works for a module at the repo root (dir='.') or in a subdirectory.
1397
+
1398
+ function generateGoRoot(a, dir = '.') {
1399
+ const buildImage = BASE_IMAGES[STACKS.GO].build(a.version);
1400
+ const runtimeImage = BASE_IMAGES[STACKS.GO].runtime();
1401
+ const p = dir === '.' ? '' : `${dir}/`;
1402
+ const bin = a.binaryName || 'app';
1403
+ const target = a.buildTarget || '.';
1404
+
1405
+ const modCopy = a.hasGoSum ? `COPY ${p}go.mod ${p}go.sum ./` : `COPY ${p}go.mod ./`;
1406
+ const srcCopies = [];
1407
+ if (a.hasRootGoFiles) srcCopies.push(`COPY ${p}*.go ./`);
1408
+ for (const d of (a.goSourceDirs || [])) srcCopies.push(`COPY ${p}${d}/ ./${d}/`);
1409
+ if (srcCopies.length === 0) srcCopies.push(`COPY ${p}*.go ./`);
1410
+
1411
+ const dockerfile = `
1412
+ # ─── Stage 1: Build ───────────────────────────────────────
1413
+ FROM ${buildImage} AS build
1414
+ WORKDIR /src
1415
+
1416
+ ${modCopy}
1417
+ RUN --mount=type=cache,target=/go/pkg/mod \\
1418
+ go mod download
1419
+
1420
+ ${srcCopies.join('\n')}
1421
+ RUN --mount=type=cache,target=/go/pkg/mod \\
1422
+ --mount=type=cache,target=/root/.cache/go-build \\
1423
+ CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/${bin} ${target}
1424
+
1425
+ # ─── Stage 2: Runtime ─────────────────────────────────────
1426
+ FROM ${runtimeImage}
1427
+ WORKDIR /app
1428
+
1429
+ RUN apk add --no-cache ca-certificates && \\
1430
+ addgroup -S appgroup && adduser -S appuser -G appgroup
1431
+
1432
+ COPY --from=build /out/${bin} /usr/local/bin/${bin}
1433
+
1434
+ USER appuser
1435
+ EXPOSE ${a.port}
1436
+ ENTRYPOINT ["/usr/local/bin/${bin}"]`.trim();
1437
+
1438
+ const improvements = [
1439
+ `Go binary built static (CGO disabled) and shipped on ${runtimeImage} — tiny, no toolchain in the final image.`,
1440
+ `Build target is \`${target}\` and the binary is \`${bin}\` (from the module path). Verify these match your main package.`,
1441
+ `EXPOSE ${a.port} is a default — set the real port (or pass a hint) if your service listens elsewhere.`,
1442
+ 'No HEALTHCHECK is emitted (Go apps have no standard health route) — add one pointing at your readiness endpoint.',
1443
+ ];
1444
+ return { dockerfile, dockerignore: goDockerignore(), improvements };
1445
+ }
1446
+
1447
+ // ── Rust ───────────────────────────────────────────────────────────────────────
1448
+ // Build the release binary on the toolchain image, ship it on debian-slim
1449
+ // (Rust links glibc by default, so a glibc runtime is the safe choice).
1450
+
1451
+ function generateRustRoot(a, dir = '.') {
1452
+ const buildImage = BASE_IMAGES[STACKS.RUST].build(a.version);
1453
+ const runtimeImage = BASE_IMAGES[STACKS.RUST].runtime();
1454
+ const p = dir === '.' ? '' : `${dir}/`;
1455
+ const bin = a.binaryName || 'app';
1456
+ const lockedFlag = a.hasLock ? ' --locked' : '';
1457
+
1458
+ const manifestCopy = a.hasLock
1459
+ ? `COPY ${p}Cargo.toml ${p}Cargo.lock ./`
1460
+ : `COPY ${p}Cargo.toml ./`;
1461
+ const buildRsCopy = a.hasBuildRs ? `COPY ${p}build.rs ./\n` : '';
1462
+
1463
+ const dockerfile = `
1464
+ # ─── Stage 1: Build ───────────────────────────────────────
1465
+ FROM ${buildImage} AS build
1466
+ WORKDIR /app
1467
+
1468
+ ${manifestCopy}
1469
+ ${buildRsCopy}COPY ${p}src/ src/
1470
+ RUN --mount=type=cache,target=/usr/local/cargo/registry \\
1471
+ --mount=type=cache,target=/app/target \\
1472
+ cargo build --release${lockedFlag} --bin ${bin} && \\
1473
+ cp target/release/${bin} /out-${bin}
1474
+
1475
+ # ─── Stage 2: Runtime ─────────────────────────────────────
1476
+ FROM ${runtimeImage}
1477
+ WORKDIR /app
1478
+
1479
+ RUN apt-get update && \\
1480
+ apt-get install --no-install-recommends -y ca-certificates && \\
1481
+ rm -rf /var/lib/apt/lists/* && \\
1482
+ groupadd --system appgroup && \\
1483
+ useradd --system --gid appgroup --home-dir /app appuser
1484
+
1485
+ COPY --from=build /out-${bin} /usr/local/bin/${bin}
1486
+
1487
+ USER appuser
1488
+ EXPOSE ${a.port}
1489
+ ENTRYPOINT ["/usr/local/bin/${bin}"]`.trim();
1490
+
1491
+ const improvements = [
1492
+ `Rust release binary \`${bin}\` built with cargo${a.hasLock ? ' --locked' : ''} and shipped on ${runtimeImage}.`,
1493
+ `EXPOSE ${a.port} is a default — set the real port (or pass a hint) if your service listens elsewhere.`,
1494
+ 'If you build a fully static binary (musl target), you can swap the runtime to a smaller base like distroless or scratch.',
1495
+ 'No HEALTHCHECK is emitted — add one pointing at your readiness endpoint.',
1496
+ ];
1497
+ return { dockerfile, dockerignore: rustDockerignore(), improvements };
1498
+ }
1499
+
1313
1500
  // ── Helpers ──────────────────────────────────────────────────────────────────
1314
1501
 
1315
1502
  function installLine(packageManager, prodOnly) {
@@ -1412,4 +1599,28 @@ TestResults
1412
1599
  Dockerfile
1413
1600
  .dockerignore`.trim();
1414
1601
  }
1602
+
1603
+ function goDockerignore() {
1604
+ return `.git
1605
+ .gitignore
1606
+ .env
1607
+ .env.*
1608
+ *.md
1609
+ bin
1610
+ dist
1611
+ vendor
1612
+ Dockerfile
1613
+ .dockerignore`.trim();
1614
+ }
1615
+
1616
+ function rustDockerignore() {
1617
+ return `.git
1618
+ .gitignore
1619
+ .env
1620
+ .env.*
1621
+ *.md
1622
+ target
1623
+ Dockerfile
1624
+ .dockerignore`.trim();
1625
+ }
1415
1626
  module.exports = { generateDockerfile, addPowerDockerfileHeader };