@akash-chowdhury-24/deployhub 2.0.32 → 2.0.34

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": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.32",
3
+ "version": "2.0.34",
4
4
  "description": "Zero-configuration deployment and artifact manager",
5
5
  "type": "module",
6
6
  "main": "./src/cli/index.js",
@@ -11,6 +11,10 @@ import {
11
11
  runHealthChecksForEnvs,
12
12
  formatHealthCheckAllSummary,
13
13
  } from '../utils/health-check.js';
14
+ import {
15
+ runDockerPortPublishChecksForEnvs,
16
+ verifyStageShouldRun,
17
+ } from '../utils/docker-port-publish.js';
14
18
  import { createLogger } from '../logger/index.js';
15
19
 
16
20
  /**
@@ -76,12 +80,28 @@ export function registerDeployCommand(program) {
76
80
  const deployed = /** @type {string[]} */ (
77
81
  ctx.state.deployedTargets || targets
78
82
  );
79
- return anyEnvHasResolvableHealthCheckUrl(ctx.config, deployed);
83
+ return verifyStageShouldRun(
84
+ ctx.config,
85
+ deployed,
86
+ anyEnvHasResolvableHealthCheckUrl
87
+ );
80
88
  },
81
89
  async run(ctx) {
82
90
  const deployed = /** @type {string[]} */ (
83
91
  ctx.state.deployedTargets || targets
84
92
  );
93
+ const portOutcome = await runDockerPortPublishChecksForEnvs(
94
+ ctx.config,
95
+ deployed,
96
+ { requireRunning: true }
97
+ );
98
+ if (portOutcome.failures.length > 0) {
99
+ throw new Error(portOutcome.failures[0].error);
100
+ }
101
+ for (const r of portOutcome.results) {
102
+ console.log(chalk.green(`Docker port published (${r.envName}): ${r.message}`));
103
+ }
104
+
85
105
  const { results, failures } = await runHealthChecksForEnvs(
86
106
  ctx.config,
87
107
  deployed
@@ -40,6 +40,10 @@ import {
40
40
  formatRemoteDockerDaemonOk,
41
41
  } from '../utils/docker-remote.js';
42
42
  import { resolveDockerRemoteMode } from '../utils/docker-remote-mode.js';
43
+ import {
44
+ checkEnvDockerPortPublish,
45
+ resolveDockerPublishPort,
46
+ } from '../utils/docker-port-publish.js';
43
47
  import {
44
48
  buildPhpFpmUnitListCommand,
45
49
  formatPhpFpmMissingError,
@@ -591,6 +595,22 @@ export async function runDeploymentChecks(config, envName, envConfig) {
591
595
  })
592
596
  );
593
597
  }
598
+
599
+ const publishPort = resolveDockerPublishPort(config, settings, envName);
600
+ if (dockerRemoteMode === 'ssh' || publishPort != null) {
601
+ checks.push(
602
+ await runCheck('Docker port published', async () => {
603
+ const outcome = await checkEnvDockerPortPublish(config, envName, {
604
+ requireRunning: false,
605
+ });
606
+ return {
607
+ name: 'Docker port published',
608
+ pass: outcome.pass,
609
+ message: outcome.message,
610
+ };
611
+ })
612
+ );
613
+ }
594
614
  }
595
615
 
596
616
  if (deployType === 'kubernetes') {
@@ -164,6 +164,7 @@ export function registerEnvCommand(program) {
164
164
  existingEnvNames: Object.keys(config.environments || {}),
165
165
  deployType: opts.method,
166
166
  nonInteractive: Boolean(opts.yes),
167
+ portDefault: config.port,
167
168
  }
168
169
  );
169
170
  } catch (err) {
@@ -171,12 +172,21 @@ export function registerEnvCommand(program) {
171
172
  process.exit(1);
172
173
  }
173
174
 
175
+ // Docker env add must not inherit top-level port via singleConfig — that
176
+ // silently stamps another environment's port onto the new env. Interactive
177
+ // answers carry deployAnswers.port; --yes omits it so SSH deploy/doctor
178
+ // fail with the published-port error instead of a wrong fallback.
179
+ const entrySingleConfig =
180
+ deployAnswers.deployType === 'docker'
181
+ ? { framework: singleConfig?.framework, port: deployAnswers.port }
182
+ : singleConfig;
183
+
174
184
  config.environments[name] = buildServerEnvEntry(
175
185
  deployAnswers,
176
186
  projectType,
177
187
  config.project,
178
188
  backendConfig,
179
- singleConfig
189
+ entrySingleConfig
180
190
  );
181
191
 
182
192
  if (!config.defaultEnvironment) {
@@ -382,6 +382,7 @@ export function registerInitCommand(program) {
382
382
  {
383
383
  ...(opts.envName ? { envName: opts.envName } : {}),
384
384
  existingEnvNames: Object.keys(environments),
385
+ portDefault: singleConfig?.port ?? backendConfig?.port,
385
386
  }
386
387
  );
387
388
  primaryDeployType = deployAnswers.deployType;
@@ -7,6 +7,10 @@ import {
7
7
  runHealthChecksForEnvs,
8
8
  formatHealthCheckAllSummary,
9
9
  } from '../utils/health-check.js';
10
+ import {
11
+ runDockerPortPublishChecksForEnvs,
12
+ verifyStageShouldRun,
13
+ } from '../utils/docker-port-publish.js';
10
14
 
11
15
  /**
12
16
  * Standalone verify — same per-env URL resolution and summary as the deploy pipeline stage.
@@ -31,7 +35,7 @@ export async function runVerify(config, envFlag, options = {}) {
31
35
  };
32
36
  }
33
37
 
34
- if (!anyEnvHasResolvableHealthCheckUrl(config, targets)) {
38
+ if (!verifyStageShouldRun(config, targets, anyEnvHasResolvableHealthCheckUrl)) {
35
39
  const label =
36
40
  targets.length === 1
37
41
  ? `environment "${targets[0]}"`
@@ -47,28 +51,47 @@ export async function runVerify(config, envFlag, options = {}) {
47
51
  };
48
52
  }
49
53
 
54
+ const portOutcome = await runDockerPortPublishChecksForEnvs(config, targets, {
55
+ requireRunning: true,
56
+ ...options,
57
+ });
58
+
50
59
  const { results, failures } = await runHealthChecksForEnvs(config, targets, options);
51
- const multi = targets.length > 1 || failures.length > 0;
60
+ const allFailures = [
61
+ ...portOutcome.failures.map((f) => ({ envName: f.envName, url: '', error: f.error })),
62
+ ...failures,
63
+ ];
64
+ const portResults = portOutcome.results.map((r) => ({
65
+ envName: r.envName,
66
+ url: '',
67
+ status: 0,
68
+ elapsed: 0,
69
+ message: r.message,
70
+ }));
71
+ const mergedResults = [...portResults, ...results];
72
+ const multi = targets.length > 1 || allFailures.length > 0;
52
73
  const summary = multi ? formatHealthCheckAllSummary(results, failures) : '';
53
74
 
54
75
  let message = '';
55
- if (failures.length === 0 && results.length === 1 && !multi) {
76
+ if (allFailures.length === 0 && results.length === 1 && portOutcome.results.length === 0 && !multi) {
56
77
  const r = results[0];
57
78
  message = `Health check passed (${r.envName}): HTTP ${r.status} (${r.elapsed}ms)`;
58
- } else if (failures.length === 0 && results.length === 0) {
79
+ } else if (allFailures.length === 0 && results.length === 0 && portOutcome.results.length === 1) {
80
+ message = portOutcome.results[0].message;
81
+ } else if (allFailures.length === 0 && results.length === 0 && portOutcome.results.length === 0) {
59
82
  message = `No health check URL configured for the selected environment(s).`;
60
83
  }
61
84
 
62
85
  return {
63
- ok: failures.length === 0 && results.length > 0,
86
+ ok: allFailures.length === 0 && (results.length > 0 || portOutcome.results.length > 0),
64
87
  targets,
65
- results,
66
- failures,
88
+ results: mergedResults,
89
+ failures: allFailures,
67
90
  summary,
68
91
  message:
69
92
  message ||
70
- (failures.length > 0
71
- ? failures[0].error
93
+ (allFailures.length > 0
94
+ ? allFailures[0].error
72
95
  : `All ${results.length} environment(s) passed health checks.`),
73
96
  skippedDisabled,
74
97
  };
@@ -8,6 +8,11 @@ import {
8
8
  anyEnvHasResolvableHealthCheckUrl,
9
9
  runHealthChecksForEnvs,
10
10
  } from '../utils/health-check.js';
11
+ import {
12
+ anyDockerEnvHasPublishPort,
13
+ runDockerPortPublishChecksForEnvs,
14
+ verifyStageShouldRun,
15
+ } from '../utils/docker-port-publish.js';
11
16
  import { getProjectVersion } from '../utils/version.js';
12
17
  import { resolveBuildId } from '../utils/build-id.js';
13
18
  import { ensureDeployScaffold } from '../utils/scaffold.js';
@@ -224,12 +229,25 @@ export function buildPipelineStages(config, cwd, state) {
224
229
  {
225
230
  name: 'verify',
226
231
  enabled: (ctx) => {
227
- if (ctx.config.pipeline.verify !== true) return false;
232
+ if (ctx.config.pipeline.verify !== true) {
233
+ const deployed = /** @type {string[]} */ (ctx.state.deployedTargets || []);
234
+ return anyDockerEnvHasPublishPort(ctx.config, deployed);
235
+ }
228
236
  const deployed = /** @type {string[]} */ (ctx.state.deployedTargets || []);
229
- return anyEnvHasResolvableHealthCheckUrl(ctx.config, deployed);
237
+ return verifyStageShouldRun(
238
+ ctx.config,
239
+ deployed,
240
+ anyEnvHasResolvableHealthCheckUrl
241
+ );
230
242
  },
231
243
  async run(ctx) {
232
244
  const deployed = /** @type {string[]} */ (ctx.state.deployedTargets || []);
245
+ const portOutcome = await runDockerPortPublishChecksForEnvs(ctx.config, deployed, {
246
+ requireRunning: true,
247
+ });
248
+ if (portOutcome.failures.length > 0) {
249
+ throw new Error(portOutcome.failures[0].error);
250
+ }
233
251
  const { results, failures } = await runHealthChecksForEnvs(ctx.config, deployed);
234
252
  if (failures.length > 0) {
235
253
  throw new Error(failures[0].error);
@@ -58,13 +58,15 @@ export function backendProcessNamePromptMessage(framework, projectName, projectT
58
58
  * @param {'frontend'|'backend'|'both'} projectType
59
59
  * @param {Record<string, unknown>|null} backendConfig
60
60
  * @param {{
61
- * envName?: string,
62
- * existingEnvNames?: string[],
63
- * deployType?: string,
64
- * nonInteractive?: boolean,
65
- * }} [options]
66
- * when envName is set, skip the name prompt; existingEnvNames blocks in-session / config duplicates
67
- * — deployType skips the method list; nonInteractive uses defaults (requires deployType)
61
+ * envName?: string,
62
+ * existingEnvNames?: string[],
63
+ * deployType?: string,
64
+ * nonInteractive?: boolean,
65
+ * portDefault?: number,
66
+ * }} [options]
67
+ * — when envName is set, skip the name prompt; existingEnvNames blocks in-session / config duplicates
68
+ * — deployType skips the method list; nonInteractive uses defaults (requires deployType)
69
+ * — portDefault seeds the docker "Default port" prompt (init / env add)
68
70
  */
69
71
  export async function promptServerDeployment(
70
72
  projectName,
@@ -133,7 +135,10 @@ export async function promptServerDeployment(
133
135
  }
134
136
 
135
137
  if (deployType === 'docker') {
136
- return promptDockerDeployment(base, projectName, projectType);
138
+ return promptDockerDeployment(base, projectName, projectType, {
139
+ backendConfig,
140
+ portDefault: options.portDefault,
141
+ });
137
142
  }
138
143
 
139
144
  return promptSshBasedDeployment(base, projectName, projectType, backendConfig, deployType);
@@ -306,8 +311,12 @@ async function promptKubernetesDeployment(base, projectName, projectType, option
306
311
  * @param {Record<string, string>} base
307
312
  * @param {string} projectName
308
313
  * @param {'frontend'|'backend'|'both'} projectType
314
+ * @param {{
315
+ * backendConfig?: Record<string, unknown>|null,
316
+ * portDefault?: number,
317
+ * }} [options]
309
318
  */
310
- async function promptDockerDeployment(base, projectName, projectType) {
319
+ async function promptDockerDeployment(base, projectName, projectType, options = {}) {
311
320
  const imageAnswers = await inquirer.prompt([
312
321
  {
313
322
  type: 'input',
@@ -404,7 +413,25 @@ async function promptDockerDeployment(base, projectName, projectType) {
404
413
  ]);
405
414
  }
406
415
 
407
- const { healthUrl } = await inquirer.prompt([
416
+ const portDefaultRaw = options.portDefault ?? options.backendConfig?.port;
417
+ const portDefault = Number.isInteger(Number(portDefaultRaw))
418
+ ? Number(portDefaultRaw)
419
+ : 3000;
420
+
421
+ const { port, healthUrl } = await inquirer.prompt([
422
+ {
423
+ type: 'number',
424
+ name: 'port',
425
+ message: 'Default port:',
426
+ default: portDefault,
427
+ validate: (value) => {
428
+ const n = Number(value);
429
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
430
+ return 'Enter a port number between 1 and 65535.';
431
+ }
432
+ return true;
433
+ },
434
+ },
408
435
  {
409
436
  type: 'input',
410
437
  name: 'healthUrl',
@@ -418,6 +445,7 @@ async function promptDockerDeployment(base, projectName, projectType) {
418
445
  remoteMode,
419
446
  ...sshAnswers,
420
447
  dockerHost: rawAnswers.dockerHost || '',
448
+ port,
421
449
  healthUrl,
422
450
  };
423
451
  }
@@ -652,6 +680,15 @@ export function buildServerEnvEntry(
652
680
  if (deployAnswers.host) settings.host = deployAnswers.host;
653
681
  if (deployAnswers.user) settings.user = deployAnswers.user;
654
682
  }
683
+ // Per-env port (same key resolveDockerPublishPort reads). Prefer the
684
+ // docker "Default port" answer; fall back to init's project-level port.
685
+ // Do not invent || 3000 — missing port must stay missing so SSH deploy
686
+ // fails loudly instead of publishing a sibling environment's port.
687
+ const rawPort = deployAnswers.port ?? singleConfig?.port;
688
+ const n = Number(rawPort);
689
+ if (Number.isInteger(n) && n >= 1 && n <= 65535) {
690
+ settings.port = n;
691
+ }
655
692
  return {
656
693
  enabled: true,
657
694
  method: 'docker',
@@ -10,6 +10,13 @@ import {
10
10
  resolveDockerSshTarget,
11
11
  buildRemoteDockerCommands,
12
12
  } from '../../utils/docker-remote.js';
13
+ import {
14
+ resolveDockerPublishPort,
15
+ formatDockerSshPortRequired,
16
+ evaluateDockerPortPublish,
17
+ buildDockerInspectPortsArgs,
18
+ buildDockerInspectPortsCommand,
19
+ } from '../../utils/docker-port-publish.js';
13
20
 
14
21
  /**
15
22
  * @param {import('../../core/config.js').DeployHubConfig} config
@@ -38,6 +45,7 @@ export function createDockerProvider(config, envName, env = process.env) {
38
45
  imageOps;
39
46
  // Env-scoped like PM2/Nginx — same-daemon multi-env must not share one container name.
40
47
  const containerName = resolveDockerContainerName(config, envName);
48
+ const publishPort = resolveDockerPublishPort(config, settings, envName);
41
49
 
42
50
  function sshTarget() {
43
51
  return resolveDockerSshTarget(settings, effectiveEnv);
@@ -77,7 +85,10 @@ export function createDockerProvider(config, envName, env = process.env) {
77
85
  }
78
86
 
79
87
  if (remoteMode === 'ssh') {
80
- await deployOverSsh(imageRef);
88
+ if (publishPort == null) {
89
+ throw new Error(formatDockerSshPortRequired(envName));
90
+ }
91
+ await deployOverSsh(imageRef, publishPort);
81
92
  log.success('Docker deployment complete');
82
93
  return;
83
94
  }
@@ -90,19 +101,59 @@ export function createDockerProvider(config, envName, env = process.env) {
90
101
  { stdio: 'pipe', env: dockerEnv }
91
102
  ).catch(() => {});
92
103
 
93
- await execa('docker', ['run', '-d', '--rm', '--name', containerName, imageRef], {
104
+ /** @type {string[]} */
105
+ const runArgs = ['run', '-d', '--rm', '--name', containerName];
106
+ if (publishPort != null) {
107
+ runArgs.push('-p', `${publishPort}:${publishPort}`);
108
+ }
109
+ runArgs.push(imageRef);
110
+
111
+ await execa('docker', runArgs, {
94
112
  stdio: 'inherit',
95
113
  env: dockerEnv,
96
114
  });
97
115
 
116
+ if (publishPort != null) {
117
+ await assertLocalPortPublished(dockerEnv, publishPort);
118
+ }
119
+
98
120
  log.success('Docker deployment complete');
99
121
  }
100
122
 
123
+ /**
124
+ * @param {Record<string, string>} dockerEnv
125
+ * @param {number} port
126
+ */
127
+ async function assertLocalPortPublished(dockerEnv, port) {
128
+ try {
129
+ const inspected = await execa('docker', buildDockerInspectPortsArgs(containerName), {
130
+ stdio: 'pipe',
131
+ env: dockerEnv,
132
+ });
133
+ const verdict = evaluateDockerPortPublish(
134
+ { code: 0, stdout: inspected.stdout },
135
+ { containerName, port, requireRunning: false }
136
+ );
137
+ if (!verdict.pass) {
138
+ throw new Error(verdict.message);
139
+ }
140
+ if (verdict.reason === 'published') {
141
+ log.info(verdict.message);
142
+ }
143
+ } catch (err) {
144
+ if (err instanceof Error && err.message.startsWith("Container '")) {
145
+ throw err;
146
+ }
147
+ // One-shot images (hello-world) exit before inspect; verify/doctor catch long-running misses.
148
+ }
149
+ }
150
+
101
151
  /**
102
152
  * @param {string} imageRef
153
+ * @param {number} port
103
154
  */
104
- async function deployOverSsh(imageRef) {
105
- const cmds = buildRemoteDockerCommands(imageRef, containerName);
155
+ async function deployOverSsh(imageRef, port) {
156
+ const cmds = buildRemoteDockerCommands(imageRef, containerName, {}, { publishPort: port });
106
157
  const session = sshSession();
107
158
  const ssh = await session.connect();
108
159
  try {
@@ -126,6 +177,21 @@ export function createDockerProvider(config, envName, env = process.env) {
126
177
  timeoutMs: Math.max(session.defaultExecTimeoutMs, 300_000),
127
178
  });
128
179
  await session.exec(ssh, cmds.run);
180
+ const inspected = await session.execUnchecked(
181
+ ssh,
182
+ buildDockerInspectPortsCommand(containerName)
183
+ );
184
+ const verdict = evaluateDockerPortPublish(inspected, {
185
+ containerName,
186
+ port,
187
+ requireRunning: false,
188
+ });
189
+ if (!verdict.pass) {
190
+ throw new Error(verdict.message);
191
+ }
192
+ if (verdict.reason === 'published') {
193
+ log.info(verdict.message);
194
+ }
129
195
  } finally {
130
196
  ssh.dispose();
131
197
  }
@@ -15,6 +15,57 @@ import {
15
15
  EXPLICIT_IMAGE_TAG_WARNING,
16
16
  } from './docker-image.js';
17
17
 
18
+ /**
19
+ * Classify `docker pull` failure for rollback logs / interpreted-backend errors.
20
+ * @param {unknown} err
21
+ * @returns {'not found'|'auth failed'|'network error'|string}
22
+ */
23
+ export function classifyDockerPullFailure(err) {
24
+ const execErr = /** @type {{ message?: string, stderr?: string, stdout?: string }} */ (err);
25
+ const combined = `${execErr?.message || ''} ${execErr?.stderr || ''} ${execErr?.stdout || ''}`.toLowerCase();
26
+ // Docker Hub reports missing repos as "denied" — check existence first.
27
+ if (
28
+ combined.includes('manifest unknown') ||
29
+ combined.includes('not found') ||
30
+ combined.includes('repository does not exist') ||
31
+ combined.includes('no such image')
32
+ ) {
33
+ return 'not found';
34
+ }
35
+ if (
36
+ combined.includes('unauthorized') ||
37
+ combined.includes('authentication required') ||
38
+ combined.includes('access denied') ||
39
+ combined.includes('denied: requested access')
40
+ ) {
41
+ return 'auth failed';
42
+ }
43
+ if (
44
+ combined.includes('network') ||
45
+ combined.includes('timeout') ||
46
+ combined.includes('timed out') ||
47
+ combined.includes('econnrefused') ||
48
+ combined.includes('connection refused') ||
49
+ combined.includes('no such host') ||
50
+ combined.includes('dial tcp')
51
+ ) {
52
+ return 'network error';
53
+ }
54
+ const stderr = String(execErr?.stderr || '').trim().split('\n').filter(Boolean).pop();
55
+ return stderr || (err instanceof Error ? err.message : 'pull failed');
56
+ }
57
+
58
+ /**
59
+ * @param {string} imageRef
60
+ * @param {string} reason
61
+ */
62
+ export function formatImageNotLocalAndPullFailed(imageRef, reason) {
63
+ return (
64
+ `Target image ${imageRef} not found locally and could not be pulled ` +
65
+ `from the registry (${reason}).`
66
+ );
67
+ }
68
+
18
69
  /**
19
70
  * Shared Docker image build, reuse, push, and pullability logic used by
20
71
  * docker and kubernetes deploy providers.
@@ -105,6 +156,41 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
105
156
  }
106
157
  }
107
158
 
159
+ /**
160
+ * Rollback (and any skipImageReuse path): after a local-cache miss, pull the
161
+ * exact tag from the registry before falling through to artifact rebuild.
162
+ * @param {string} ref
163
+ * @returns {Promise<{ ok: true, output: string }|{ ok: false, reason: string, output: string }>}
164
+ */
165
+ async function tryPullImage(ref) {
166
+ log.info(`Pulling ${ref} from registry...`);
167
+ try {
168
+ const pulled = await execa('docker', ['pull', ref], {
169
+ stdio: 'pipe',
170
+ env: getDockerEnv(),
171
+ });
172
+ const output = [pulled.stdout, pulled.stderr].filter(Boolean).join('\n').trim();
173
+ if (output) log.info(output);
174
+ if (await imageExistsLocally(ref)) {
175
+ log.success(`Pulled ${ref}`);
176
+ return { ok: true, output };
177
+ }
178
+ return {
179
+ ok: false,
180
+ reason: 'pull reported success but image is still missing locally',
181
+ output,
182
+ };
183
+ } catch (err) {
184
+ const execErr = /** @type {{ stdout?: string, stderr?: string }} */ (err);
185
+ const output = [execErr.stdout, execErr.stderr]
186
+ .filter(Boolean)
187
+ .join('\n')
188
+ .trim();
189
+ if (output) log.info(output);
190
+ return { ok: false, reason: classifyDockerPullFailure(err), output };
191
+ }
192
+ }
193
+
108
194
  /**
109
195
  * Prefer the image already built during the pipeline `docker` stage.
110
196
  * Retag when the pipeline used `:latest` and deploy needs a version tag.
@@ -138,8 +224,15 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
138
224
  * @param {Record<string, unknown>} metadata
139
225
  * @param {string} framework
140
226
  * @param {number} port
227
+ * @param {string} [imageRef]
141
228
  */
142
- async function prepareBackendBuildContext(buildContext, metadata, framework, port) {
229
+ async function prepareBackendBuildContext(
230
+ buildContext,
231
+ metadata,
232
+ framework,
233
+ port,
234
+ imageRef = fullImage
235
+ ) {
143
236
  if (framework === 'spring') {
144
237
  const targetDir = path.join(buildContext, 'target');
145
238
  if (await fs.pathExists(targetDir)) {
@@ -199,7 +292,7 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
199
292
  if (isInterpretedBackendFramework(framework)) {
200
293
  const gap = describeInterpretedBackendGap(framework);
201
294
  throw new Error(
202
- `Cannot rebuild ${gap.ecosystem} backend image "${fullImage}" from the packaged artifact.\n` +
295
+ `Cannot rebuild ${gap.ecosystem} backend image "${imageRef}" from the packaged artifact.\n` +
203
296
  `Backend artifacts include source/manifests but not ${gap.missing}, ` +
204
297
  `so Dockerfiles that run \`${gap.installCmd}\` cannot reliably succeed from the artifact alone.\n\n` +
205
298
  'What to do instead:\n' +
@@ -286,7 +379,13 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
286
379
  generateFrontendRuntimeDockerfile(buildOutput)
287
380
  );
288
381
  } else {
289
- await prepareBackendBuildContext(buildContext, metadata, framework, port);
382
+ await prepareBackendBuildContext(
383
+ buildContext,
384
+ metadata,
385
+ framework,
386
+ port,
387
+ imageRef
388
+ );
290
389
  }
291
390
 
292
391
  await execa('docker', ['build', '-t', imageRef, '.'], {
@@ -322,6 +421,8 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
322
421
  await dockerLogin();
323
422
 
324
423
  let reused = false;
424
+ /** @type {string|null} */
425
+ let registryPullFailure = null;
325
426
  if (!options.skipImageReuse) {
326
427
  // Normal deploy: prefer pipeline image (exact tag, then :latest retag).
327
428
  reused = await ensureImageFromPipeline(imageRef);
@@ -331,16 +432,32 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
331
432
  log.info(`Using restored image ${imageRef} (skipImageReuse — no :latest retag)`);
332
433
  reused = true;
333
434
  } else {
334
- log.info(
335
- `Target image ${imageRef} not found locally — attempting rebuild from artifact`
336
- );
435
+ const pulled = await tryPullImage(imageRef);
436
+ if (pulled.ok) {
437
+ reused = true;
438
+ } else {
439
+ registryPullFailure = pulled.reason;
440
+ log.info(
441
+ `${formatImageNotLocalAndPullFailed(imageRef, pulled.reason)} — attempting rebuild from artifact`
442
+ );
443
+ }
337
444
  }
338
445
 
339
446
  let ranCompose = false;
340
447
 
341
448
  if (!reused) {
342
- const result = await buildFromArtifactContents(artifactDir, imageRef);
343
- ranCompose = Boolean(result?.ranCompose);
449
+ try {
450
+ const result = await buildFromArtifactContents(artifactDir, imageRef);
451
+ ranCompose = Boolean(result?.ranCompose);
452
+ } catch (err) {
453
+ if (registryPullFailure) {
454
+ const detail = err instanceof Error ? err.message : String(err);
455
+ throw new Error(
456
+ `${formatImageNotLocalAndPullFailed(imageRef, registryPullFailure)}\n${detail}`
457
+ );
458
+ }
459
+ throw err;
460
+ }
344
461
  }
345
462
 
346
463
  if (ranCompose) {
@@ -372,6 +489,7 @@ export function createDockerImageDeployContext(config, env = process.env, log) {
372
489
  dockerLogin,
373
490
  maybePushImage,
374
491
  ensureImageFromPipeline,
492
+ tryPullImage,
375
493
  buildFromArtifactContents,
376
494
  ensureImageReadyForDeploy,
377
495
  };
@@ -0,0 +1,328 @@
1
+ import { execa } from 'execa';
2
+ import { getEnvMethod, getEnvSettings } from '../core/environments.js';
3
+ import { resolveDockerContainerName } from './docker-container-name.js';
4
+ import { resolveDockerRemoteMode } from './docker-remote-mode.js';
5
+ import { resolveDockerSshTarget } from './docker-remote.js';
6
+ import { createSshExecSession } from '../deployment/ssh-connection.js';
7
+ import { shellQuote } from './shell-quote.js';
8
+
9
+ /** Go template: running|bindings. Empty HostIp treated as 0.0.0.0. */
10
+ export const DOCKER_INSPECT_PORT_FORMAT =
11
+ `{{if .State.Running}}running{{else}}stopped{{end}}|` +
12
+ `{{range $p, $conf := .NetworkSettings.Ports}}` +
13
+ `{{range $conf}}` +
14
+ `{{if eq .HostIp ""}}0.0.0.0{{else}}{{.HostIp}}{{end}}:{{.HostPort}}->` +
15
+ `{{end}}{{end}}`;
16
+
17
+ export const DOCKER_PORT_NOT_PUBLISHED_TRAILER =
18
+ 'The app is not reachable from outside the container.';
19
+
20
+ /**
21
+ * Exact doctor/verify failure copy when a container is running without `-p`.
22
+ * @param {string} containerName
23
+ * @param {number} port
24
+ */
25
+ export function formatDockerPortNotPublished(containerName, port) {
26
+ return (
27
+ `Container '${containerName}' is running but port ${port} is not published ` +
28
+ `on the host (no 0.0.0.0:${port}-> mapping).\n` +
29
+ DOCKER_PORT_NOT_PUBLISHED_TRAILER
30
+ );
31
+ }
32
+
33
+ /**
34
+ * @param {string} containerName
35
+ * @param {number} port
36
+ */
37
+ export function formatDockerPortPublished(containerName, port) {
38
+ return `Container '${containerName}' publishes 0.0.0.0:${port}->`;
39
+ }
40
+
41
+ /**
42
+ * @param {string} envName
43
+ */
44
+ export function formatDockerSshPortRequired(envName) {
45
+ return (
46
+ `Docker remote.mode "ssh" requires a published port. ` +
47
+ `Set environments.${envName}.config.port (or top-level port) so the container ` +
48
+ `is started with -p <port>:<port>. Deploying without -p leaves the app unreachable.`
49
+ );
50
+ }
51
+
52
+ /**
53
+ * @param {unknown} raw
54
+ * @returns {number|null}
55
+ */
56
+ function parsePublishPort(raw) {
57
+ const n = Number(raw);
58
+ if (!Number.isInteger(n) || n < 1 || n > 65535) return null;
59
+ return n;
60
+ }
61
+
62
+ /**
63
+ * Top-level `config.port` is the legacy single-env fallback (scenario 2A).
64
+ * With two or more docker environments it must not be inherited by a later
65
+ * env that never stored its own `config.port` — that would silently publish
66
+ * another environment's port.
67
+ *
68
+ * @param {import('../core/config.js').DeployHubConfig} [config]
69
+ * @param {string} [envName]
70
+ */
71
+ function shouldUseTopLevelDockerPortFallback(config = {}, envName) {
72
+ const dockerNames = Object.entries(config.environments || {})
73
+ .filter(([, entry]) => getEnvMethod(entry) === 'docker')
74
+ .map(([name]) => name);
75
+
76
+ if (dockerNames.length <= 1) return true;
77
+ if (!envName) return false;
78
+
79
+ if (config.defaultEnvironment && dockerNames.includes(config.defaultEnvironment)) {
80
+ return envName === config.defaultEnvironment;
81
+ }
82
+ if (
83
+ config.unprefixedSecretEnvironment &&
84
+ dockerNames.includes(config.unprefixedSecretEnvironment)
85
+ ) {
86
+ return envName === config.unprefixedSecretEnvironment;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ /**
92
+ * Configured host/container publish port. No silent default — missing means
93
+ * SSH must fail loudly instead of running unpublished.
94
+ *
95
+ * Fallback chain is still `settings.port ?? config.port` (plus `backend.port`)
96
+ * for legacy single-env configs and the original docker env. It is not used
97
+ * for additional docker environments that omitted `config.port`.
98
+ *
99
+ * @param {import('../core/config.js').DeployHubConfig} [config]
100
+ * @param {Record<string, unknown>} [settings]
101
+ * @param {string} [envName]
102
+ * @returns {number|null}
103
+ */
104
+ export function resolveDockerPublishPort(config = {}, settings = {}, envName) {
105
+ const own = parsePublishPort(settings.port);
106
+ if (own != null) return own;
107
+ if (!shouldUseTopLevelDockerPortFallback(config, envName)) return null;
108
+ return parsePublishPort(config.port ?? config.backend?.port);
109
+ }
110
+
111
+ /**
112
+ * @param {string} stdout
113
+ * @param {number} port
114
+ */
115
+ export function inspectShowsHostPortMapping(stdout, port) {
116
+ return String(stdout || '').includes(`0.0.0.0:${port}->`);
117
+ }
118
+
119
+ /**
120
+ * @param {string} containerName
121
+ */
122
+ export function buildDockerInspectPortsArgs(containerName) {
123
+ return ['inspect', '--format', DOCKER_INSPECT_PORT_FORMAT, containerName];
124
+ }
125
+
126
+ /**
127
+ * @param {string} containerName
128
+ */
129
+ export function buildDockerInspectPortsCommand(containerName) {
130
+ const args = buildDockerInspectPortsArgs(containerName);
131
+ return `docker inspect --format ${shellQuote(args[2])} ${shellQuote(containerName)}`;
132
+ }
133
+
134
+ /**
135
+ * @param {{ code?: number|null, stdout?: string, stderr?: string }} result
136
+ * @param {{ containerName: string, port: number, requireRunning: boolean }} opts
137
+ * @returns {{ pass: boolean, reason: 'published'|'not-running'|'unpublished'|'missing-container', message: string }}
138
+ */
139
+ export function evaluateDockerPortPublish(result, opts) {
140
+ const { containerName, port, requireRunning } = opts;
141
+ const code = result.code;
142
+ const raw = String(result.stdout || '');
143
+ let running = true;
144
+ let mappings = raw;
145
+ const pipe = raw.indexOf('|');
146
+ if (pipe >= 0 && (raw.startsWith('running|') || raw.startsWith('stopped|'))) {
147
+ running = raw.startsWith('running|');
148
+ mappings = raw.slice(pipe + 1);
149
+ }
150
+
151
+ if ((code !== 0 && code !== null && code !== undefined) || !running) {
152
+ if (!requireRunning) {
153
+ return {
154
+ pass: true,
155
+ reason: 'not-running',
156
+ message: `No running container '${containerName}' — deploy first, then re-run this check.`,
157
+ };
158
+ }
159
+ if (!running && (code === 0 || code === null || code === undefined)) {
160
+ return {
161
+ pass: false,
162
+ reason: 'missing-container',
163
+ message:
164
+ `Container '${containerName}' is not running — cannot confirm port ${port} is published.`,
165
+ };
166
+ }
167
+ return {
168
+ pass: false,
169
+ reason: 'missing-container',
170
+ message:
171
+ `Container '${containerName}' is not running — cannot confirm port ${port} is published.`,
172
+ };
173
+ }
174
+
175
+ if (inspectShowsHostPortMapping(mappings, port)) {
176
+ return {
177
+ pass: true,
178
+ reason: 'published',
179
+ message: formatDockerPortPublished(containerName, port),
180
+ };
181
+ }
182
+
183
+ return {
184
+ pass: false,
185
+ reason: 'unpublished',
186
+ message: formatDockerPortNotPublished(containerName, port),
187
+ };
188
+ }
189
+
190
+ /**
191
+ * @param {import('../core/config.js').DeployHubConfig} config
192
+ * @param {string[]} envNames
193
+ */
194
+ export function anyDockerEnvHasPublishPort(config, envNames) {
195
+ return (envNames || []).some((envName) => {
196
+ const entry = config.environments?.[envName];
197
+ if (getEnvMethod(entry) !== 'docker') return false;
198
+ const settings = getEnvSettings(entry);
199
+ return resolveDockerPublishPort(config, settings, envName) != null;
200
+ });
201
+ }
202
+
203
+ /**
204
+ * Whether the post-deploy verify stage should run (HTTP health and/or docker port).
205
+ * @param {import('../core/config.js').DeployHubConfig} config
206
+ * @param {string[]} envNames
207
+ * @param {(config: import('../core/config.js').DeployHubConfig, envNames: string[]) => boolean} hasHealthUrl
208
+ */
209
+ export function verifyStageShouldRun(config, envNames, hasHealthUrl) {
210
+ return Boolean(hasHealthUrl(config, envNames) || anyDockerEnvHasPublishPort(config, envNames));
211
+ }
212
+
213
+ /**
214
+ * Inspect one docker environment's running container for 0.0.0.0:<port>->.
215
+ *
216
+ * @param {import('../core/config.js').DeployHubConfig} config
217
+ * @param {string} envName
218
+ * @param {{ requireRunning?: boolean, env?: Record<string, string|undefined> }} [options]
219
+ */
220
+ export async function checkEnvDockerPortPublish(config, envName, options = {}) {
221
+ const entry = config.environments?.[envName];
222
+ const method = getEnvMethod(entry);
223
+ if (method !== 'docker') {
224
+ return { skipped: true, envName, pass: true, message: '' };
225
+ }
226
+
227
+ const settings = getEnvSettings(entry);
228
+ const env = options.env || process.env;
229
+ const requireRunning = options.requireRunning !== false;
230
+ const port = resolveDockerPublishPort(config, settings, envName);
231
+ const remoteMode = resolveDockerRemoteMode(settings, env);
232
+ const containerName = resolveDockerContainerName(config, envName);
233
+
234
+ if (port == null) {
235
+ if (remoteMode === 'ssh') {
236
+ return {
237
+ skipped: false,
238
+ envName,
239
+ pass: false,
240
+ message: formatDockerSshPortRequired(envName),
241
+ };
242
+ }
243
+ return { skipped: true, envName, pass: true, message: '' };
244
+ }
245
+
246
+ /** @type {{ code?: number|null, stdout?: string, stderr?: string }} */
247
+ let result;
248
+ if (remoteMode === 'ssh') {
249
+ const target = resolveDockerSshTarget(settings, env);
250
+ const session = createSshExecSession({
251
+ ...target,
252
+ keyPath: target.keyPath ? String(target.keyPath) : undefined,
253
+ env,
254
+ });
255
+ const ssh = await session.connect();
256
+ try {
257
+ result = await session.execUnchecked(ssh, buildDockerInspectPortsCommand(containerName));
258
+ } finally {
259
+ ssh.dispose();
260
+ }
261
+ } else {
262
+ try {
263
+ const inspected = await execa('docker', buildDockerInspectPortsArgs(containerName), {
264
+ stdio: 'pipe',
265
+ env: { ...process.env, ...env },
266
+ });
267
+ result = { code: 0, stdout: inspected.stdout, stderr: inspected.stderr };
268
+ } catch (err) {
269
+ const execErr = /** @type {{ exitCode?: number, stdout?: string, stderr?: string }} */ (err);
270
+ result = {
271
+ code: execErr.exitCode ?? 1,
272
+ stdout: execErr.stdout || '',
273
+ stderr: execErr.stderr || (err instanceof Error ? err.message : String(err)),
274
+ };
275
+ }
276
+ }
277
+
278
+ const verdict = evaluateDockerPortPublish(result, {
279
+ containerName,
280
+ port,
281
+ requireRunning,
282
+ });
283
+ return {
284
+ skipped: false,
285
+ envName,
286
+ pass: verdict.pass,
287
+ message: verdict.message,
288
+ reason: verdict.reason,
289
+ };
290
+ }
291
+
292
+ /**
293
+ * @param {import('../core/config.js').DeployHubConfig} config
294
+ * @param {string[]} envNames
295
+ * @param {{ requireRunning?: boolean, env?: Record<string, string|undefined> }} [options]
296
+ */
297
+ export async function runDockerPortPublishChecksForEnvs(config, envNames, options = {}) {
298
+ /** @type {{ envName: string, message: string }[]} */
299
+ const results = [];
300
+ /** @type {{ envName: string, error: string }[]} */
301
+ const failures = [];
302
+
303
+ for (const envName of envNames || []) {
304
+ const outcome = await checkEnvDockerPortPublish(config, envName, options);
305
+ if (outcome.skipped) continue;
306
+ if (outcome.pass) {
307
+ results.push({ envName: outcome.envName, message: outcome.message });
308
+ } else {
309
+ failures.push({ envName: outcome.envName, error: outcome.message });
310
+ }
311
+ }
312
+
313
+ return { results, failures };
314
+ }
315
+
316
+ export default {
317
+ DOCKER_INSPECT_PORT_FORMAT,
318
+ formatDockerPortNotPublished,
319
+ formatDockerPortPublished,
320
+ formatDockerSshPortRequired,
321
+ resolveDockerPublishPort,
322
+ inspectShowsHostPortMapping,
323
+ evaluateDockerPortPublish,
324
+ anyDockerEnvHasPublishPort,
325
+ verifyStageShouldRun,
326
+ checkEnvDockerPortPublish,
327
+ runDockerPortPublishChecksForEnvs,
328
+ };
@@ -178,8 +178,9 @@ export async function probeRemoteDockerPs(target) {
178
178
  * @param {string} imageRef
179
179
  * @param {string} containerName
180
180
  * @param {Record<string, string>} [runEnv]
181
+ * @param {{ publishPort?: number|null }} [options]
181
182
  */
182
- export function buildRemoteDockerCommands(imageRef, containerName, runEnv = {}) {
183
+ export function buildRemoteDockerCommands(imageRef, containerName, runEnv = {}, options = {}) {
183
184
  const image = shellQuote(imageRef);
184
185
  const name = shellQuote(containerName);
185
186
  /** @type {string[]} */
@@ -188,12 +189,15 @@ export function buildRemoteDockerCommands(imageRef, containerName, runEnv = {})
188
189
  envFlags.push(`-e ${shellQuote(`${key}=${value}`)}`);
189
190
  }
190
191
  const envArg = envFlags.length > 0 ? `${envFlags.join(' ')} ` : '';
192
+ const publishPort = options.publishPort;
193
+ const pFlag =
194
+ publishPort != null ? `-p ${shellQuote(`${publishPort}:${publishPort}`)} ` : '';
191
195
 
192
196
  return {
193
197
  stop: `docker stop ${name} 2>/dev/null || true`,
194
198
  rm: `docker rm -f ${name} 2>/dev/null || true`,
195
199
  pull: `docker pull ${image}`,
196
- run: `docker run -d --rm --name ${name} ${envArg}${image}`,
200
+ run: `docker run -d --rm --name ${name} ${pFlag}${envArg}${image}`,
197
201
  ps: `docker ps --filter ${shellQuote(`name=^/${containerName}$`)} --format ${shellQuote('{{.Status}}')}`,
198
202
  info: 'docker info',
199
203
  /**
@@ -11,6 +11,9 @@ import {
11
11
  getEnvMethod,
12
12
  resolveDefaultEnvironmentName,
13
13
  } from '../../core/environments.js';
14
+ import {
15
+ runDockerPortPublishChecksForEnvs,
16
+ } from '../docker-port-publish.js';
14
17
  import fs from 'fs-extra';
15
18
  import path from 'path';
16
19
 
@@ -32,6 +35,19 @@ async function rollbackTarget(config, artifactDir, envName, meta) {
32
35
 
33
36
  const provider = getDeploymentProvider(method, config, envName);
34
37
  await provider.rollback(artifactDir, meta);
38
+
39
+ // Same post-deploy port-publish check as the deploy pipeline verify stage
40
+ // (`runDockerPortPublishChecksForEnvs`). SSH/EC2/VM skip; docker envs fail
41
+ // if the restored container is up without 0.0.0.0:<port>->.
42
+ const portOutcome = await runDockerPortPublishChecksForEnvs(config, [envName], {
43
+ requireRunning: true,
44
+ });
45
+ if (portOutcome.failures.length > 0) {
46
+ throw new Error(portOutcome.failures[0].error);
47
+ }
48
+ for (const r of portOutcome.results) {
49
+ log.success(r.message);
50
+ }
35
51
  }
36
52
 
37
53
  /**