@akash-chowdhury-24/deployhub 2.0.32 → 2.0.33

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.33",
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
  }
@@ -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
  /**