@akash-chowdhury-24/deployhub 2.0.4 → 2.0.6

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.
@@ -2,6 +2,17 @@ import { execa } from 'execa';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import { createLogger } from '../../logger/index.js';
5
+ import { extractArtifact } from '../../artifact/engine.js';
6
+ import {
7
+ describeInterpretedBackendGap,
8
+ generateDotnetRuntimeDockerfile,
9
+ generateFrontendRuntimeDockerfile,
10
+ generateGoRuntimeDockerfile,
11
+ generateSpringRuntimeDockerfile,
12
+ isFrontendStaticFramework,
13
+ isInterpretedBackendFramework,
14
+ resolveDockerImageRef,
15
+ } from '../../utils/docker-image.js';
5
16
 
6
17
  /**
7
18
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -11,8 +22,8 @@ import { createLogger } from '../../logger/index.js';
11
22
  export function createDockerProvider(config, envName, env = process.env) {
12
23
  const log = createLogger('docker');
13
24
 
14
- const imageName = env.DOCKER_IMAGE_NAME || config.project;
15
- const imageTag = env.DOCKER_IMAGE_TAG || config.version || 'latest';
25
+ const { fullImage, latestImage, legacyLatestImage, imageTag } =
26
+ resolveDockerImageRef(config, env);
16
27
  const registryUrl = env.DOCKER_REGISTRY_URL || '';
17
28
  const registryUser = env.DOCKER_REGISTRY_USERNAME || '';
18
29
  const registryToken = env.DOCKER_REGISTRY_TOKEN || '';
@@ -27,12 +38,12 @@ export function createDockerProvider(config, envName, env = process.env) {
27
38
  return dockerEnv;
28
39
  }
29
40
 
30
- const fullImage = registryUrl && !imageName.includes('/')
31
- ? `${registryUrl.replace(/\/$/, '')}/${imageName}:${imageTag}`
32
- : `${imageName}:${imageTag}`;
41
+ function hasRegistryCredentials() {
42
+ return Boolean(registryUser && registryToken);
43
+ }
33
44
 
34
45
  async function dockerLogin() {
35
- if (!registryUser || !registryToken) return;
46
+ if (!hasRegistryCredentials()) return;
36
47
  const registry = registryUrl || 'https://index.docker.io/v1/';
37
48
  log.info('Logging in to container registry...');
38
49
  await execa(
@@ -47,48 +58,284 @@ export function createDockerProvider(config, envName, env = process.env) {
47
58
  }
48
59
 
49
60
  /**
50
- * @param {string} artifactDir
61
+ * Push only when registry credentials are configured. Avoids a noisy failed
62
+ * push to Docker Hub for local-only image names.
51
63
  */
52
- async function deploy(artifactDir) {
53
- log.info(`Deploying via Docker (image: ${fullImage})...`);
54
- const dockerEnv = getDockerEnv();
64
+ async function maybePushImage() {
65
+ if (!hasRegistryCredentials()) {
66
+ log.info(
67
+ 'docker push skipped (DOCKER_REGISTRY_USERNAME/TOKEN not set — local image only)'
68
+ );
69
+ return;
70
+ }
55
71
 
56
- const composePath = path.join(artifactDir, 'docker-compose.yml');
57
- const dockerfilePath = path.join(artifactDir, 'Dockerfile');
72
+ log.info(`Pushing ${fullImage} to registry...`);
73
+ await execa('docker', ['push', fullImage], {
74
+ stdio: 'inherit',
75
+ env: getDockerEnv(),
76
+ });
77
+ log.success(`Pushed ${fullImage}`);
78
+ }
58
79
 
59
- const hasCompose = await fs.pathExists(composePath);
60
- const hasDockerfile = await fs.pathExists(dockerfilePath);
80
+ /**
81
+ * @param {string} ref
82
+ */
83
+ async function imageExists(ref) {
84
+ try {
85
+ await execa('docker', ['image', 'inspect', ref], {
86
+ stdio: 'pipe',
87
+ env: getDockerEnv(),
88
+ });
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
61
94
 
62
- await dockerLogin();
95
+ /**
96
+ * Prefer the image already built during the pipeline `docker` stage.
97
+ * Retag when the pipeline used `:latest` and deploy needs a version tag.
98
+ */
99
+ async function ensureImageFromPipeline() {
100
+ if (await imageExists(fullImage)) {
101
+ log.info(`Reusing existing image ${fullImage}`);
102
+ return true;
103
+ }
63
104
 
64
- if (hasCompose) {
65
- await execa(
66
- 'docker',
67
- ['compose', 'up', '-d', '--build'],
68
- { cwd: artifactDir, stdio: 'inherit', env: dockerEnv }
69
- );
70
- } else if (hasDockerfile) {
71
- await execa('docker', ['build', '-t', fullImage, '.'], {
72
- cwd: artifactDir,
105
+ const candidates = [...new Set([latestImage, legacyLatestImage])].filter(
106
+ (ref) => ref !== fullImage
107
+ );
108
+
109
+ for (const candidate of candidates) {
110
+ if (!(await imageExists(candidate))) continue;
111
+ log.info(`Re-tagging pipeline image ${candidate} → ${fullImage}`);
112
+ await execa('docker', ['tag', candidate, fullImage], {
73
113
  stdio: 'inherit',
74
- env: dockerEnv,
114
+ env: getDockerEnv(),
75
115
  });
76
- await execa('docker', ['push', fullImage], {
116
+ return true;
117
+ }
118
+
119
+ return false;
120
+ }
121
+
122
+ /**
123
+ * Backend artifacts omit lockfiles/node_modules. Prefer a pre-built binary
124
+ * runtime image when present; otherwise fail with an actionable error.
125
+ * @param {string} buildContext
126
+ * @param {Record<string, unknown>} metadata
127
+ * @param {string} framework
128
+ * @param {number} port
129
+ */
130
+ async function prepareBackendBuildContext(buildContext, metadata, framework, port) {
131
+ if (framework === 'spring') {
132
+ const targetDir = path.join(buildContext, 'target');
133
+ if (await fs.pathExists(targetDir)) {
134
+ const jars = (await fs.readdir(targetDir)).filter((f) => f.endsWith('.jar'));
135
+ if (jars.length > 0) {
136
+ const jarRel = `target/${jars[0]}`;
137
+ log.info(`Building runtime image from pre-built JAR (${jarRel})...`);
138
+ await fs.writeFile(
139
+ path.join(buildContext, 'Dockerfile'),
140
+ generateSpringRuntimeDockerfile(jarRel, port)
141
+ );
142
+ return;
143
+ }
144
+ }
145
+ }
146
+
147
+ if (framework === 'go') {
148
+ const binDir = path.join(buildContext, 'bin');
149
+ if (await fs.pathExists(binDir)) {
150
+ const bins = await fs.readdir(binDir);
151
+ if (bins.length > 0) {
152
+ const binRel = `bin/${bins[0]}`;
153
+ log.info(`Building runtime image from pre-built Go binary (${binRel})...`);
154
+ await fs.writeFile(
155
+ path.join(buildContext, 'Dockerfile'),
156
+ generateGoRuntimeDockerfile(binRel, port)
157
+ );
158
+ return;
159
+ }
160
+ }
161
+ }
162
+
163
+ if (framework === 'dotnet') {
164
+ const publishDir =
165
+ /** @type {string} */ (metadata.buildOutput) ||
166
+ config.buildOutput ||
167
+ 'publish';
168
+ const publishPath = path.join(buildContext, publishDir);
169
+ if (await fs.pathExists(publishPath)) {
170
+ log.info(`Building runtime image from pre-built .NET output (${publishDir}/)...`);
171
+ await fs.writeFile(
172
+ path.join(buildContext, 'Dockerfile'),
173
+ generateDotnetRuntimeDockerfile(publishDir, port)
174
+ );
175
+ return;
176
+ }
177
+ }
178
+
179
+ const dockerfilePath = path.join(buildContext, 'Dockerfile');
180
+ if (!(await fs.pathExists(dockerfilePath))) {
181
+ throw new Error(
182
+ 'No Dockerfile found in artifact and no pipeline image to reuse. ' +
183
+ 'Add a Dockerfile and enable pipeline.docker so the image is built from project source.'
184
+ );
185
+ }
186
+
187
+ // Interpreted backends (Node/Python/PHP/Ruby): never rebuild from artifact.
188
+ // Artifacts ship source + manifest files, not installed dependency trees.
189
+ if (isInterpretedBackendFramework(framework)) {
190
+ const gap = describeInterpretedBackendGap(framework);
191
+ throw new Error(
192
+ `Cannot rebuild ${gap.ecosystem} backend image "${fullImage}" from the packaged artifact.\n` +
193
+ `Backend artifacts include source/manifests but not ${gap.missing}, ` +
194
+ `so Dockerfiles that run \`${gap.installCmd}\` cannot reliably succeed from the artifact alone.\n\n` +
195
+ 'What to do instead:\n' +
196
+ ' 1. Enable pipeline.docker in deployhub.config.json (default when Docker deploy is selected).\n' +
197
+ ' 2. Run a full `deployhub build` so the image is built from the project root (with full deps).\n' +
198
+ ' 3. Deploy will reuse that local image (retag/push/run) — it will not rebuild from the artifact.\n\n' +
199
+ `Standalone \`deployhub deploy\` without a pre-built local image is not supported for ${gap.ecosystem} backends.`
200
+ );
201
+ }
202
+
203
+ // Remaining backends (unknown frameworks): try the packaged Dockerfile, but warn.
204
+ log.warn(
205
+ 'Building backend image from extracted artifact. Prefer pipeline.docker so the image ' +
206
+ 'is built once from full project source, then reused on deploy.'
207
+ );
208
+ }
209
+
210
+ /**
211
+ * Standalone deploy fallback: extract the packaged artifact and build a
212
+ * runtime image from pre-built output (frontend) or compiled backend artifacts.
213
+ * Never builds from artifactDir root — that only has zip/metadata + a source Dockerfile.
214
+ * @param {string} artifactDir
215
+ */
216
+ async function buildFromArtifactContents(artifactDir) {
217
+ const zipPath = path.join(artifactDir, 'artifact.zip');
218
+ if (!(await fs.pathExists(zipPath))) {
219
+ throw new Error(
220
+ `No local image found for ${fullImage} and no artifact.zip to build from. ` +
221
+ 'Enable pipeline.docker so the image is built from project source, or run deployhub build first.'
222
+ );
223
+ }
224
+
225
+ const buildContext = path.join(artifactDir, '_docker_build');
226
+ await fs.remove(buildContext);
227
+ await extractArtifact(artifactDir, buildContext);
228
+
229
+ try {
230
+ const metadataPath = path.join(buildContext, 'metadata.json');
231
+ const metadata = (await fs.pathExists(metadataPath))
232
+ ? await fs.readJson(metadataPath)
233
+ : {};
234
+
235
+ const projectType = metadata.projectType || config.projectType || 'frontend';
236
+ const framework =
237
+ metadata.framework ||
238
+ config.framework ||
239
+ config.frontend?.framework ||
240
+ '';
241
+ const buildOutput =
242
+ metadata.buildOutput ||
243
+ config.buildOutput ||
244
+ config.frontend?.buildOutput ||
245
+ 'dist';
246
+ const port =
247
+ Number(metadata.port) ||
248
+ config.port ||
249
+ config.backend?.port ||
250
+ 3000;
251
+
252
+ const composePath = path.join(buildContext, 'docker-compose.yml');
253
+ if (await fs.pathExists(composePath)) {
254
+ log.info('Building via docker compose from extracted artifact...');
255
+ await execa('docker', ['compose', 'up', '-d', '--build'], {
256
+ cwd: buildContext,
257
+ stdio: 'inherit',
258
+ env: getDockerEnv(),
259
+ });
260
+ return { ranCompose: true };
261
+ }
262
+
263
+ const isStaticFrontend =
264
+ projectType === 'frontend' || isFrontendStaticFramework(framework);
265
+
266
+ if (isStaticFrontend) {
267
+ const outputDir = path.join(buildContext, buildOutput);
268
+ if (!(await fs.pathExists(outputDir))) {
269
+ throw new Error(
270
+ `Frontend artifact is missing build output "${buildOutput}". ` +
271
+ 'Cannot build a runtime image from this artifact.'
272
+ );
273
+ }
274
+ log.info(
275
+ `Building runtime image from pre-built ${buildOutput}/ (no source rebuild)...`
276
+ );
277
+ await fs.writeFile(
278
+ path.join(buildContext, 'Dockerfile'),
279
+ generateFrontendRuntimeDockerfile(buildOutput)
280
+ );
281
+ } else {
282
+ await prepareBackendBuildContext(buildContext, metadata, framework, port);
283
+ }
284
+
285
+ await execa('docker', ['build', '-t', fullImage, '.'], {
286
+ cwd: buildContext,
77
287
  stdio: 'inherit',
78
- env: dockerEnv,
79
- }).catch(() => {
80
- log.warn('docker push skipped (registry may be local or push not configured)');
288
+ env: getDockerEnv(),
81
289
  });
82
- await execa('docker', ['run', '-d', '--rm', '--name', config.project, fullImage], {
83
- stdio: 'inherit',
290
+ return { ranCompose: false };
291
+ } finally {
292
+ await fs.remove(buildContext).catch(() => {});
293
+ }
294
+ }
295
+
296
+ /**
297
+ * @param {string} artifactDir
298
+ */
299
+ async function deploy(artifactDir) {
300
+ log.info(`Deploying via Docker (image: ${fullImage})...`);
301
+ const dockerEnv = getDockerEnv();
302
+
303
+ await dockerLogin();
304
+
305
+ const reused = await ensureImageFromPipeline();
306
+ let ranCompose = false;
307
+
308
+ if (!reused) {
309
+ const result = await buildFromArtifactContents(artifactDir);
310
+ ranCompose = Boolean(result?.ranCompose);
311
+ }
312
+
313
+ if (ranCompose) {
314
+ log.success('Docker deployment complete');
315
+ return;
316
+ }
317
+
318
+ await maybePushImage();
319
+
320
+ // Keep :latest in sync when deploy uses a version tag
321
+ if (imageTag !== 'latest' && fullImage !== latestImage) {
322
+ await execa('docker', ['tag', fullImage, latestImage], {
323
+ stdio: 'pipe',
84
324
  env: dockerEnv,
85
- });
86
- } else {
87
- throw new Error(
88
- 'No Dockerfile or docker-compose.yml found in artifact. Add one to your project or enable Docker in pipeline config.'
89
- );
325
+ }).catch(() => {});
90
326
  }
91
327
 
328
+ await execa(
329
+ 'docker',
330
+ ['rm', '-f', config.project],
331
+ { stdio: 'pipe', env: dockerEnv }
332
+ ).catch(() => {});
333
+
334
+ await execa('docker', ['run', '-d', '--rm', '--name', config.project, fullImage], {
335
+ stdio: 'inherit',
336
+ env: dockerEnv,
337
+ });
338
+
92
339
  log.success('Docker deployment complete');
93
340
  }
94
341
 
@@ -3,6 +3,7 @@ import fs from 'fs-extra';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
5
  import { createLogger } from '../../logger/index.js';
6
+ import { sanitizeK8sName } from '../../utils/kubernetes-manifests.js';
6
7
 
7
8
  /**
8
9
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -15,6 +16,7 @@ export function createKubernetesProvider(config, envName, env = process.env) {
15
16
  const kubeconfig = env.KUBECONFIG || path.join(os.homedir(), '.kube', 'config');
16
17
  const context = env.KUBE_CONTEXT || '';
17
18
  const namespace = env.KUBE_NAMESPACE || config.project || 'default';
19
+ const deploymentName = sanitizeK8sName(config.project || 'app');
18
20
 
19
21
  function getKubectlEnv() {
20
22
  const expanded = kubeconfig.replace(/^~/, os.homedir());
@@ -66,8 +68,8 @@ export function createKubernetesProvider(config, envName, env = process.env) {
66
68
  kubectlArgs([
67
69
  'set',
68
70
  'image',
69
- `deployment/${config.project}`,
70
- `${config.project}=${imageName}:${imageTag}`,
71
+ `deployment/${deploymentName}`,
72
+ `${deploymentName}=${imageName}:${imageTag}`,
71
73
  ]),
72
74
  { stdio: 'pipe', env: getKubectlEnv() }
73
75
  ).catch(() => {
@@ -80,7 +82,7 @@ export function createKubernetesProvider(config, envName, env = process.env) {
80
82
 
81
83
  async function rollback(artifactDir) {
82
84
  log.info('Rolling back Kubernetes deployment...');
83
- await execa('kubectl', kubectlArgs(['rollout', 'undo', `deployment/${config.project}`]), {
85
+ await execa('kubectl', kubectlArgs(['rollout', 'undo', `deployment/${deploymentName}`]), {
84
86
  stdio: 'inherit',
85
87
  env: getKubectlEnv(),
86
88
  }).catch(async () => {
@@ -105,7 +107,7 @@ export function createKubernetesProvider(config, envName, env = process.env) {
105
107
  try {
106
108
  await execa(
107
109
  'kubectl',
108
- kubectlArgs(['rollout', 'status', `deployment/${config.project}`, '--timeout=30s']),
110
+ kubectlArgs(['rollout', 'status', `deployment/${deploymentName}`, '--timeout=30s']),
109
111
  { stdio: 'pipe', env: getKubectlEnv() }
110
112
  );
111
113
  return true;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * Shared Docker image naming for pipeline builds and deploy.
3
+ */
4
+
5
+ /**
6
+ * @param {import('../core/config.js').DeployHubConfig} config
7
+ * @param {Record<string, string|undefined>} [env]
8
+ * @returns {{
9
+ * imageName: string,
10
+ * imageTag: string,
11
+ * fullImage: string,
12
+ * latestImage: string,
13
+ * legacyLatestImage: string,
14
+ * }}
15
+ */
16
+ export function resolveDockerImageRef(config, env = process.env) {
17
+ const imageName = env.DOCKER_IMAGE_NAME || config.project;
18
+ const imageTag = env.DOCKER_IMAGE_TAG || config.version || 'latest';
19
+ const registryUrl = env.DOCKER_REGISTRY_URL || '';
20
+
21
+ const repository =
22
+ registryUrl && !imageName.includes('/')
23
+ ? `${registryUrl.replace(/\/$/, '')}/${imageName}`
24
+ : imageName;
25
+
26
+ return {
27
+ imageName,
28
+ imageTag,
29
+ fullImage: `${repository}:${imageTag}`,
30
+ latestImage: `${repository}:latest`,
31
+ legacyLatestImage: `${config.project}:latest`,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Runtime-only Dockerfile for frontend artifacts that already contain built static output.
37
+ * Used when deploy must build from an artifact (no source / no package-lock).
38
+ * @param {string} [buildOutput]
39
+ * @returns {string}
40
+ */
41
+ export function generateFrontendRuntimeDockerfile(buildOutput = 'dist') {
42
+ const output = buildOutput.replace(/^\/+|\/+$/g, '') || 'dist';
43
+ return `# Generated by DeployHub for artifact deploy (pre-built static output)
44
+ FROM nginx:alpine
45
+ COPY ${output}/ /usr/share/nginx/html/
46
+ EXPOSE 80
47
+ CMD ["nginx", "-g", "daemon off;"]
48
+ `;
49
+ }
50
+
51
+ /**
52
+ * @param {string} jarRelativePath e.g. target/app.jar
53
+ * @param {number} [port]
54
+ */
55
+ export function generateSpringRuntimeDockerfile(jarRelativePath, port = 8080) {
56
+ return `# Generated by DeployHub for artifact deploy (pre-built JAR)
57
+ FROM eclipse-temurin:17-jre-alpine
58
+ WORKDIR /app
59
+ COPY ${jarRelativePath} app.jar
60
+ EXPOSE ${port}
61
+ CMD ["java", "-jar", "app.jar"]
62
+ `;
63
+ }
64
+
65
+ /**
66
+ * @param {string} binaryRelativePath e.g. bin/app
67
+ * @param {number} [port]
68
+ */
69
+ export function generateGoRuntimeDockerfile(binaryRelativePath, port = 8080) {
70
+ return `# Generated by DeployHub for artifact deploy (pre-built Go binary)
71
+ FROM alpine:3.19
72
+ WORKDIR /app
73
+ RUN apk add --no-cache ca-certificates
74
+ COPY ${binaryRelativePath} ./app
75
+ RUN chmod +x ./app
76
+ EXPOSE ${port}
77
+ CMD ["./app"]
78
+ `;
79
+ }
80
+
81
+ /**
82
+ * @param {string} publishDir
83
+ * @param {number} [port]
84
+ */
85
+ export function generateDotnetRuntimeDockerfile(publishDir = 'publish', port = 5000) {
86
+ const dir = publishDir.replace(/^\/+|\/+$/g, '') || 'publish';
87
+ return `# Generated by DeployHub for artifact deploy (pre-built .NET publish output)
88
+ FROM mcr.microsoft.com/dotnet/aspnet:8.0
89
+ WORKDIR /app
90
+ COPY ${dir}/ .
91
+ EXPOSE ${port}
92
+ ENV ASPNETCORE_URLS=http://+:${port}
93
+ CMD ["dotnet", "App.dll"]
94
+ `;
95
+ }
96
+
97
+ /**
98
+ * @param {string} framework
99
+ * @returns {boolean}
100
+ */
101
+ export function isFrontendStaticFramework(framework) {
102
+ return ['react', 'vue', 'angular', 'svelte', 'astro', 'vanilla'].includes(framework || '');
103
+ }
104
+
105
+ /**
106
+ * Node-style backends whose generated Dockerfiles run npm ci and need a lockfile.
107
+ * @param {string} framework
108
+ */
109
+ export function isNodeBackendFramework(framework) {
110
+ return ['express', 'nestjs', 'fastify', 'koa', 'nextjs'].includes(framework || '');
111
+ }
112
+
113
+ /**
114
+ * Interpreted backends that install deps at image-build time (pip/composer/bundle/npm).
115
+ * Their artifacts lack vendor/venv/node_modules, so standalone artifact rebuild is unsupported.
116
+ * @param {string} framework
117
+ */
118
+ export function isInterpretedBackendFramework(framework) {
119
+ return [
120
+ ...['express', 'nestjs', 'fastify', 'koa', 'nextjs'],
121
+ 'fastapi',
122
+ 'django',
123
+ 'flask',
124
+ 'laravel',
125
+ 'symfony',
126
+ 'rails',
127
+ ].includes(framework || '');
128
+ }
129
+
130
+ /**
131
+ * Human-readable dependency gap for interpreted backend artifact rebuilds.
132
+ * @param {string} framework
133
+ * @returns {{ ecosystem: string, missing: string, installCmd: string }}
134
+ */
135
+ export function describeInterpretedBackendGap(framework) {
136
+ if (isNodeBackendFramework(framework)) {
137
+ return {
138
+ ecosystem: 'Node',
139
+ missing: 'package-lock.json / node_modules',
140
+ installCmd: 'npm ci',
141
+ };
142
+ }
143
+ if (['fastapi', 'django', 'flask'].includes(framework)) {
144
+ return {
145
+ ecosystem: 'Python',
146
+ missing: 'a pre-installed virtualenv / site-packages (artifacts only ship requirements.txt + source)',
147
+ installCmd: 'pip install',
148
+ };
149
+ }
150
+ if (['laravel', 'symfony'].includes(framework)) {
151
+ return {
152
+ ecosystem: 'PHP',
153
+ missing: 'vendor/ (Composer dependencies are not packaged into the artifact)',
154
+ installCmd: 'composer install',
155
+ };
156
+ }
157
+ if (framework === 'rails') {
158
+ return {
159
+ ecosystem: 'Ruby',
160
+ missing: 'vendor/bundle / installed gems (artifacts only ship Gemfile + lock)',
161
+ installCmd: 'bundle install',
162
+ };
163
+ }
164
+ return {
165
+ ecosystem: 'backend',
166
+ missing: 'installed dependencies (not packaged into the artifact)',
167
+ installCmd: 'dependency install',
168
+ };
169
+ }
170
+
171
+ export default {
172
+ resolveDockerImageRef,
173
+ generateFrontendRuntimeDockerfile,
174
+ generateSpringRuntimeDockerfile,
175
+ generateGoRuntimeDockerfile,
176
+ generateDotnetRuntimeDockerfile,
177
+ isFrontendStaticFramework,
178
+ isNodeBackendFramework,
179
+ isInterpretedBackendFramework,
180
+ describeInterpretedBackendGap,
181
+ };