@dockerforge/core 0.1.1 → 0.1.3
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dockerforge/core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
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",
|
|
@@ -707,6 +707,36 @@ async function analyseNode(projectPath, files, hints, projectRootPath = null, is
|
|
|
707
707
|
// Role
|
|
708
708
|
const role = await detectRole(projectPath, pkg);
|
|
709
709
|
|
|
710
|
+
// Detect which conventional source directories actually exist on disk, so the
|
|
711
|
+
// generator copies real directories instead of guessing the layout (e.g. app/ vs src/).
|
|
712
|
+
const SOURCE_DIR_CANDIDATES = [
|
|
713
|
+
'src', 'app', 'pages', 'components', 'lib', 'styles',
|
|
714
|
+
'public', 'server', 'config', 'content', 'i18n', 'locales', 'hooks', 'utils',
|
|
715
|
+
];
|
|
716
|
+
const sourceDirs = [];
|
|
717
|
+
for (const d of SOURCE_DIR_CANDIDATES) {
|
|
718
|
+
try { if ((await fs.stat(path.join(projectPath, d))).isDirectory()) sourceDirs.push(d); }
|
|
719
|
+
catch { /* absent */ }
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Detect framework build/runtime config files that exist, so we copy real filenames
|
|
723
|
+
// instead of guessing a single hard-coded one. Next.js also reads its config at runtime.
|
|
724
|
+
const FRAMEWORK_CONFIG_CANDIDATES = [
|
|
725
|
+
'next.config.js', 'next.config.mjs', 'next.config.cjs', 'next.config.ts',
|
|
726
|
+
'tailwind.config.js', 'tailwind.config.ts', 'tailwind.config.cjs', 'tailwind.config.mjs',
|
|
727
|
+
'components.json', 'jsconfig.json',
|
|
728
|
+
'remix.config.js', 'svelte.config.js', 'nuxt.config.ts', 'nuxt.config.js',
|
|
729
|
+
];
|
|
730
|
+
const frameworkConfigFiles = [];
|
|
731
|
+
for (const f of FRAMEWORK_CONFIG_CANDIDATES) {
|
|
732
|
+
try { if ((await fs.stat(path.join(projectPath, f))).isFile()) frameworkConfigFiles.push(f); }
|
|
733
|
+
catch { /* absent */ }
|
|
734
|
+
}
|
|
735
|
+
// The config Next.js reads on `next start` (first match wins) — must be in the runtime image.
|
|
736
|
+
const nextRuntimeConfig = framework === 'nextjs'
|
|
737
|
+
? (frameworkConfigFiles.find(f => f.startsWith('next.config.')) || null)
|
|
738
|
+
: null;
|
|
739
|
+
|
|
710
740
|
// Detect sibling directories referenced via require('../xxx') in the entry point.
|
|
711
741
|
// These are root-level dirs that sit alongside the service dir and must be COPYed
|
|
712
742
|
// into the image so cross-directory requires resolve at runtime.
|
|
@@ -731,6 +761,9 @@ async function analyseNode(projectPath, files, hints, projectRootPath = null, is
|
|
|
731
761
|
rootBuildDeps, // root-level dirs imported by vite config via '../dirname' — must be copied before build
|
|
732
762
|
hasScopedPackages,
|
|
733
763
|
siblingDeps, // root-level dirs referenced via require('../xxx') — must be COPYed into runtime image
|
|
764
|
+
sourceDirs, // conventional source dirs that actually exist on disk (real, not guessed)
|
|
765
|
+
frameworkConfigFiles, // framework build/runtime config files present (next.config, tailwind.config, ...)
|
|
766
|
+
nextRuntimeConfig, // the next.config.* file Next.js reads at runtime, or null
|
|
734
767
|
assumptions,
|
|
735
768
|
};
|
|
736
769
|
}
|
|
@@ -6,8 +6,12 @@ const path = require('path');
|
|
|
6
6
|
const { STACKS, BASE_IMAGES } = require('../constants');
|
|
7
7
|
const { isSecretLikeEnvKey, isSecretLikeEnvValue } = require('../security/security');
|
|
8
8
|
|
|
9
|
-
const STATIC_RUNTIME_IMAGE = 'nginx:1.27-alpine';
|
|
10
9
|
const PNPM_VERSION = '9';
|
|
10
|
+
const STATIC_SERVER_PACKAGE = 'serve@14.2.4';
|
|
11
|
+
|
|
12
|
+
// Frameworks that produce a long-running Node server (SSR, API routes, dynamic rendering).
|
|
13
|
+
// These run with `next start` / a framework server instead of a generic static file server.
|
|
14
|
+
const SERVER_RENDERED_FRAMEWORKS = new Set(['nextjs', 'remix', 'sveltekit', 'nuxt']);
|
|
11
15
|
|
|
12
16
|
function installPnpmGlobalCmd() {
|
|
13
17
|
return `npm install -g pnpm@${PNPM_VERSION}`;
|
|
@@ -32,39 +36,20 @@ function runtimeStartCmd(a) {
|
|
|
32
36
|
return ['node', `${buildOut}/${entry.replace(/^\.\//, '')}`];
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
function
|
|
36
|
-
return
|
|
37
|
-
}
|
|
39
|
+
function staticServeRuntimeStage(baseImage, port, buildAssetCopyLine, outputDir) {
|
|
40
|
+
return `FROM ${baseImage}
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
return `server {
|
|
41
|
-
listen ${port};
|
|
42
|
-
root /usr/share/nginx/html;
|
|
43
|
-
index index.html;
|
|
42
|
+
WORKDIR /app
|
|
44
43
|
|
|
45
|
-
|
|
46
|
-
add_header X-Content-Type-Options "nosniff";
|
|
47
|
-
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
|
44
|
+
ENV NODE_ENV=production
|
|
48
45
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
46
|
+
RUN npm install -g ${STATIC_SERVER_PACKAGE} && npm cache clean --force
|
|
47
|
+
${buildAssetCopyLine}
|
|
48
|
+
USER node
|
|
53
49
|
|
|
54
|
-
|
|
55
|
-
|
|
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');
|
|
50
|
+
EXPOSE ${port}
|
|
51
|
+
${simpleHttpHealthcheck(port)}
|
|
52
|
+
CMD ["serve", "-s", "${outputDir}", "-l", "${port}"]`;
|
|
68
53
|
}
|
|
69
54
|
|
|
70
55
|
function simpleHttpHealthcheck(port) {
|
|
@@ -189,6 +174,22 @@ function nodeSourceCopyBlock(a, configCopyLine = '', frontendAssetCopyLine = '')
|
|
|
189
174
|
return blocks.join('\n');
|
|
190
175
|
}
|
|
191
176
|
|
|
177
|
+
// Build-stage source copy for Next.js. Copies the config files and source directories that
|
|
178
|
+
// actually exist on disk (detected by the analyser) instead of guessing app/ vs src/.
|
|
179
|
+
function nextSourceCopyBlock(a, rootConfigFiles = []) {
|
|
180
|
+
const lines = [];
|
|
181
|
+
const configFiles = [...new Set([
|
|
182
|
+
...rootConfigFiles.filter(f => !['package.json', a.lockFile].includes(f)),
|
|
183
|
+
...(a.frameworkConfigFiles || []),
|
|
184
|
+
])];
|
|
185
|
+
if (configFiles.length > 0) lines.push(`COPY ${configFiles.join(' ')} ./`);
|
|
186
|
+
const dirs = (Array.isArray(a.sourceDirs) && a.sourceDirs.length > 0)
|
|
187
|
+
? a.sourceDirs
|
|
188
|
+
: ['app', 'pages', 'components', 'public']; // fallback only if nothing was detected
|
|
189
|
+
for (const d of dirs) lines.push(`COPY ${d}/ ./${d}/`);
|
|
190
|
+
return lines.join('\n');
|
|
191
|
+
}
|
|
192
|
+
|
|
192
193
|
function pythonBuilderImage(a) {
|
|
193
194
|
return a.hasNativeDeps ? `python:${a.version}` : BASE_IMAGES[STACKS.PYTHON](a.version);
|
|
194
195
|
}
|
|
@@ -273,6 +274,9 @@ function buildEnvBlock(envVars) {
|
|
|
273
274
|
}
|
|
274
275
|
|
|
275
276
|
function buildCommandPrefix(service) {
|
|
277
|
+
// Server-rendered frameworks (Next.js, Remix, ...) run their own build; the CRA-specific
|
|
278
|
+
// env prefix (DISABLE_ESLINT_PLUGIN etc.) does not apply and just adds noise.
|
|
279
|
+
if (SERVER_RENDERED_FRAMEWORKS.has(service.framework)) return '';
|
|
276
280
|
// Fire for CRA regardless of how detection landed — framework='cra', role='frontend',
|
|
277
281
|
// or any build command that invokes react-scripts directly.
|
|
278
282
|
const isReactScripts =
|
|
@@ -668,8 +672,7 @@ ENTRYPOINT ["dotnet", "${be.projectName}.dll"]`.trim());
|
|
|
668
672
|
}
|
|
669
673
|
improvements.push('HEALTHCHECK should probe /health for each runtime service; add a /health endpoint returning 2xx where missing');
|
|
670
674
|
|
|
671
|
-
|
|
672
|
-
return { dockerfile, dockerignore, improvements, nginxConf: collectedNginxConf };
|
|
675
|
+
return { dockerfile, dockerignore, improvements };
|
|
673
676
|
}
|
|
674
677
|
|
|
675
678
|
// ── Node at root (original behaviour) ───────────────────────────────────────
|
|
@@ -690,10 +693,28 @@ function generateNodeRoot(a, envVars = [], rootConfigFiles = []) {
|
|
|
690
693
|
const buildOut = a.buildOutputDir || 'dist';
|
|
691
694
|
const sourceCopyBlock = nodeSourceCopyBlock(a, configCopyLine, frontendAssetCopyLine);
|
|
692
695
|
|
|
693
|
-
//
|
|
694
|
-
//
|
|
695
|
-
//
|
|
696
|
-
if (a.
|
|
696
|
+
// Next.js (and other SSR frameworks): a Node server, NOT a static site.
|
|
697
|
+
// Build with `next build`, then run `next start` on a lean Node runtime. Serving the
|
|
698
|
+
// .next output via a generic static server would break SSR, API routes, and dynamic rendering.
|
|
699
|
+
if (a.framework === 'nextjs') {
|
|
700
|
+
const nextSrcCopy = nextSourceCopyBlock(a, rootConfigFiles);
|
|
701
|
+
const hasPublic = Array.isArray(a.sourceDirs) && a.sourceDirs.includes('public');
|
|
702
|
+
const publicCopy = hasPublic ? `COPY --from=builder /app/public ./public\n` : '';
|
|
703
|
+
const runtimeConfigCopy = a.nextRuntimeConfig
|
|
704
|
+
? `COPY --from=builder /app/${a.nextRuntimeConfig} ./${a.nextRuntimeConfig}\n`
|
|
705
|
+
: '';
|
|
706
|
+
// Prune dev deps to production IN THE BUILDER, then copy node_modules into the runtime
|
|
707
|
+
// stage. This avoids a second install at runtime — which would re-hit the registry (and,
|
|
708
|
+
// for private/scoped packages, need the npmrc secret a second time). The builder already
|
|
709
|
+
// fetched and authenticated everything; pruning just drops the dev-only packages.
|
|
710
|
+
const secretMount = a.hasScopedPackages ? '--mount=type=secret,id=npmrc,target=/root/.npmrc ' : '';
|
|
711
|
+
const prodPrune =
|
|
712
|
+
a.packageManager === 'pnpm' ? 'RUN pnpm prune --prod'
|
|
713
|
+
: a.packageManager === 'yarn' ? `RUN ${secretMount}yarn install --frozen-lockfile --production --ignore-scripts && yarn cache clean`
|
|
714
|
+
: 'RUN npm prune --omit=dev';
|
|
715
|
+
// node images ship npm + yarn, but not pnpm — install it in the runtime stage if needed.
|
|
716
|
+
const runtimePmSetup = a.packageManager === 'pnpm' ? `RUN ${installPnpmGlobalCmd()}\n` : '';
|
|
717
|
+
|
|
697
718
|
dockerfile = `
|
|
698
719
|
# ─── Stage 1: Build ───────────────────────────────────────
|
|
699
720
|
${builderFrom(baseImage, 'builder')}
|
|
@@ -703,23 +724,68 @@ WORKDIR /app
|
|
|
703
724
|
COPY ${a.lockFile} package.json ./
|
|
704
725
|
${buildInstall}
|
|
705
726
|
|
|
706
|
-
# Copy source
|
|
707
|
-
${
|
|
727
|
+
# Copy source and build-time config
|
|
728
|
+
${nextSrcCopy}
|
|
708
729
|
RUN ${buildPrefix}${a.buildCommand}
|
|
709
730
|
|
|
731
|
+
# Drop dev dependencies so only production node_modules carry into the runtime image
|
|
732
|
+
${prodPrune}
|
|
733
|
+
|
|
710
734
|
# ─── Stage 2: Runtime ─────────────────────────────────────
|
|
711
|
-
FROM ${
|
|
735
|
+
FROM ${baseImage}
|
|
712
736
|
|
|
713
|
-
|
|
714
|
-
|
|
737
|
+
WORKDIR /app
|
|
738
|
+
|
|
739
|
+
${buildEnvBlock(envVars)}
|
|
740
|
+
|
|
741
|
+
# Next.js runs as a Node server. Copy production deps + build output from the builder —
|
|
742
|
+
# no second install here, so the (often private) registry is only contacted once, in the builder.
|
|
743
|
+
${runtimePmSetup}COPY --from=builder /app/package.json ./package.json
|
|
744
|
+
COPY --from=builder /app/node_modules ./node_modules
|
|
745
|
+
COPY --from=builder /app/${buildOut} ./${buildOut}
|
|
746
|
+
${publicCopy}${runtimeConfigCopy}${runtimeUserBlock(true)}
|
|
715
747
|
|
|
748
|
+
${nodeStopSignalBlock()}
|
|
716
749
|
EXPOSE ${a.port}
|
|
717
750
|
${simpleHttpHealthcheck(a.port)}
|
|
718
|
-
CMD
|
|
751
|
+
CMD ${startCmdJson}`.trim();
|
|
752
|
+
|
|
753
|
+
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.');
|
|
755
|
+
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
|
+
if (a.hasScopedPackages) {
|
|
757
|
+
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.');
|
|
758
|
+
}
|
|
759
|
+
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.');
|
|
760
|
+
return { dockerfile, dockerignore: nodeDockerignore(), improvements };
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
// CRA / pure-frontend SPA: runtime is a static file server, not a Node app.
|
|
764
|
+
// react-scripts is a devDep — it won't be present after --production install,
|
|
765
|
+
// and react-scripts start is a dev server anyway. Server-rendered frameworks are
|
|
766
|
+
// explicitly excluded so they never get the static-file-server treatment.
|
|
767
|
+
const isStaticSpa = (a.role === 'frontend' || a.framework === 'cra')
|
|
768
|
+
&& !SERVER_RENDERED_FRAMEWORKS.has(a.framework);
|
|
769
|
+
if (isStaticSpa) {
|
|
770
|
+
dockerfile = `
|
|
771
|
+
# ─── Stage 1: Build ───────────────────────────────────────
|
|
772
|
+
${builderFrom(baseImage, 'builder')}
|
|
773
|
+
|
|
774
|
+
WORKDIR /app
|
|
775
|
+
|
|
776
|
+
COPY ${a.lockFile} package.json ./
|
|
777
|
+
${buildInstall}
|
|
778
|
+
|
|
779
|
+
# Copy source
|
|
780
|
+
${sourceCopyBlock}
|
|
781
|
+
RUN ${buildPrefix}${a.buildCommand}
|
|
782
|
+
|
|
783
|
+
# ─── Stage 2: Runtime ─────────────────────────────────────
|
|
784
|
+
${staticServeRuntimeStage(baseImage, a.port, `COPY --from=builder /app/${buildOut} ./${buildOut}`, buildOut)}`.trim();
|
|
719
785
|
|
|
720
786
|
const validationDockerfile = dockerfile;
|
|
721
|
-
improvements.push(`Frontend-only: built then served with ${
|
|
722
|
-
return withValidationVariant({ dockerfile, dockerignore: nodeDockerignore(),
|
|
787
|
+
improvements.push(`Frontend-only: built then served with ${STATIC_SERVER_PACKAGE} as the non-root node user.`);
|
|
788
|
+
return withValidationVariant({ dockerfile, dockerignore: nodeDockerignore(), improvements }, validationDockerfile);
|
|
723
789
|
|
|
724
790
|
} else {
|
|
725
791
|
const pruneCmd = nodePruneLine(a.packageManager);
|
|
@@ -807,8 +873,9 @@ function generateNodeSub(a, dir, sharedDirs, staticAssets = [], rootConfigFiles
|
|
|
807
873
|
|
|
808
874
|
let dockerfile;
|
|
809
875
|
|
|
810
|
-
if (a.hasBuild && a.role === 'frontend') {
|
|
811
|
-
//
|
|
876
|
+
if (a.hasBuild && a.role === 'frontend' && !SERVER_RENDERED_FRAMEWORKS.has(a.framework)) {
|
|
877
|
+
// Static SPA build → serve the compiled output. Server-rendered frameworks (Next.js, Remix, ...)
|
|
878
|
+
// are excluded here and fall through to the Node-server runtime branch below.
|
|
812
879
|
const stageName = 'build';
|
|
813
880
|
|
|
814
881
|
// When lock file is at project root, copy root files + service package.json and
|
|
@@ -883,18 +950,11 @@ ${sourceCopy}
|
|
|
883
950
|
${sharedConfigCopy}${buildRun}
|
|
884
951
|
|
|
885
952
|
# ─── Stage 2: Serve ───────────────────────────────────────
|
|
886
|
-
|
|
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();
|
|
953
|
+
${staticServeRuntimeStage(baseImage, a.port, `COPY --from=${stageName} ${buildOutput} ./${a.buildOutputDir || 'build'}`, a.buildOutputDir || 'build')}`.trim();
|
|
894
954
|
|
|
895
955
|
const validationDockerfile = dockerfile;
|
|
896
|
-
improvements.push(`Frontend-only: built then served with ${
|
|
897
|
-
return withValidationVariant({ dockerfile, dockerignore: nodeDockerignore(),
|
|
956
|
+
improvements.push(`Frontend-only: built then served with ${STATIC_SERVER_PACKAGE} as the non-root node user. If a backend also serves the files, merge into one multi-service build.`);
|
|
957
|
+
return withValidationVariant({ dockerfile, dockerignore: nodeDockerignore(), improvements }, validationDockerfile);
|
|
898
958
|
|
|
899
959
|
|
|
900
960
|
} else {
|
package/src/engine/index.js
CHANGED
|
@@ -132,19 +132,17 @@ function applyValidationEvidence(confidenceResult, validation) {
|
|
|
132
132
|
|
|
133
133
|
/**
|
|
134
134
|
* Strip BuildKit-specific features from a Dockerfile to produce the simple default output.
|
|
135
|
-
*
|
|
135
|
+
* Build environment prefixes stay in both outputs because they are part of the recipe.
|
|
136
136
|
*/
|
|
137
137
|
function toSimpleDockerfile(dockerfile) {
|
|
138
138
|
return dockerfile.split('\n').map(line => {
|
|
139
139
|
const isRun = /^RUN\s/.test(line);
|
|
140
140
|
if (!isRun) return line;
|
|
141
|
-
// Strip --mount=type=cache,...
|
|
142
|
-
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
'$1'
|
|
147
|
-
);
|
|
141
|
+
// Strip only --mount=type=cache,... (a pure build-speed optimisation).
|
|
142
|
+
// KEEP --mount=type=secret,... — for private/scoped registries it's the only way the
|
|
143
|
+
// default Dockerfile can authenticate (e.g. `docker build --secret id=npmrc,src=.npmrc`).
|
|
144
|
+
// An unsupplied secret mount is a no-op, so this is safe even when no secret is passed.
|
|
145
|
+
line = line.replace(/--mount=type=cache,\S+\s+/g, '');
|
|
148
146
|
return line;
|
|
149
147
|
}).join('\n');
|
|
150
148
|
}
|