@akash-chowdhury-24/deployhub 2.0.31 → 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/README.md CHANGED
@@ -855,7 +855,7 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
855
855
  1. Set `DOCKER_IMAGE_NAME` in `.env` (e.g. `myuser/myapp` for Docker Hub)
856
856
  2. For private registries (or any push): set `DOCKER_REGISTRY_USERNAME` and `DOCKER_REGISTRY_TOKEN`
857
857
  3. Leave `DOCKER_IMAGE_TAG` unset for a unique tag each build — set it only if you intentionally want a fixed tag
858
- 4. For remote Docker: set `DOCKER_HOST` (e.g. `ssh://ubuntu@203.0.113.10`)
858
+ 4. For a remote Linux host, choose **Remote Linux server via SSH** at init (`remote.mode: "ssh"`) and set `SSH_HOST`, `SSH_USER`, `SSH_KEY_PATH` — DeployHub runs `docker pull`/`run` over node-ssh. Use `DOCKER_HOST` only for the advanced raw CLI transport (`tcp://` or a custom `ssh://` setup you already manage)
859
859
  5. Run `deployhub doctor`, then `git push origin main`
860
860
 
861
861
  | Variable | Description | Example | Where to get it |
@@ -865,7 +865,8 @@ DeployHub detects whether the server uses Debian-style `sites-available` or RHEL
865
865
  | `DOCKER_REGISTRY_URL` | Registry URL (optional) | `https://ghcr.io` | Registry docs |
866
866
  | `DOCKER_REGISTRY_USERNAME` | Registry user | `myuser` | Registry account |
867
867
  | `DOCKER_REGISTRY_TOKEN` | Registry password/token | *(secret)* | Docker Hub / GHCR PAT |
868
- | `DOCKER_HOST` | Remote daemon (optional) | `ssh://ubuntu@host` | Remote Docker setup |
868
+ | `DOCKER_HOST` | Advanced raw daemon URI (optional) | `tcp://host:2376` | Only if you manage TLS/ssh:// yourself |
869
+ | `SSH_HOST` / `SSH_USER` / `SSH_KEY_PATH` | Remote Linux via SSH (`remote.mode: ssh`) | `203.0.113.10` / `ubuntu` / `~/.ssh/key.pem` | Same names as EC2 |
869
870
 
870
871
  ### AWS EC2
871
872
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akash-chowdhury-24/deployhub",
3
- "version": "2.0.31",
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
@@ -31,6 +31,19 @@ import { formatPasswordlessSudoGuidance } from '../utils/nginx.js';
31
31
  import { checkImagePullability } from '../utils/docker-image-deploy.js';
32
32
  import { namespaceExists } from '../utils/kubernetes-namespace.js';
33
33
  import { resolvePhpVersion } from '../utils/php-version.js';
34
+ import {
35
+ resolveDockerSshTarget,
36
+ probeRemoteDockerPs,
37
+ formatRemoteDockerSshFailure,
38
+ formatRemoteDockerNotInstalled,
39
+ formatRemoteDockerPermissionDenied,
40
+ formatRemoteDockerDaemonOk,
41
+ } from '../utils/docker-remote.js';
42
+ import { resolveDockerRemoteMode } from '../utils/docker-remote-mode.js';
43
+ import {
44
+ checkEnvDockerPortPublish,
45
+ resolveDockerPublishPort,
46
+ } from '../utils/docker-port-publish.js';
34
47
  import {
35
48
  buildPhpFpmUnitListCommand,
36
49
  formatPhpFpmMissingError,
@@ -183,14 +196,16 @@ export async function runDeploymentChecks(config, envName, envConfig) {
183
196
 
184
197
  /** @type {CheckResult[]} */
185
198
  const checks = [];
186
- const requiredKeys = getDeploymentEnvKeys(deployType, config);
199
+ const requiredKeys = getDeploymentEnvKeys(deployType, config, settings);
187
200
 
188
201
  checks.push(
189
202
  await runCheck(`${deployType} env vars`, async () => {
190
203
  const missing = requiredKeys.filter((k) => {
191
204
  if (k === 'SSH_KEY_PATH') {
192
- return !process.env.SSH_KEY_PATH && !process.env.SSH_KEY;
205
+ return !process.env.SSH_KEY_PATH && !process.env.SSH_KEY && !settings.keyPath;
193
206
  }
207
+ if (k === 'SSH_HOST') return !(settings.host || process.env.SSH_HOST);
208
+ if (k === 'SSH_USER') return !(settings.user || process.env.SSH_USER);
194
209
  return !process.env[k];
195
210
  });
196
211
  if (missing.length > 0) {
@@ -447,22 +462,155 @@ export async function runDeploymentChecks(config, envName, envConfig) {
447
462
  }
448
463
 
449
464
  if (deployType === 'docker') {
450
- checks.push(
451
- await runCheck('Docker daemon', async () => {
452
- try {
453
- const provider = getDeploymentProvider('docker', config, envName);
454
- await provider.testConnection();
455
- return { name: 'Docker daemon', pass: true, message: 'Docker daemon reachable' };
456
- } catch (err) {
457
- const msg = err instanceof Error ? err.message : String(err);
465
+ const dockerRemoteMode = resolveDockerRemoteMode(settings, process.env);
466
+
467
+ if (dockerRemoteMode === 'ssh') {
468
+ const target = resolveDockerSshTarget(settings, process.env);
469
+ const host = target.host;
470
+ const user = target.user;
471
+ const keyPath = target.keyPath;
472
+ const sshPort = target.sshPort;
473
+
474
+ checks.push(
475
+ await runCheck('SSH key', async () => {
476
+ const result = await validateSshKeyForDoctor(
477
+ keyPath ? String(keyPath) : undefined,
478
+ process.env.SSH_KEY
479
+ );
458
480
  return {
459
- name: 'Docker daemon',
481
+ name: 'SSH key',
482
+ pass: result.ok,
483
+ message: result.message,
484
+ };
485
+ })
486
+ );
487
+
488
+ checks.push(
489
+ await runCheck('SSH host reachability', async () => {
490
+ if (!host) {
491
+ return {
492
+ name: 'SSH host reachability',
493
+ pass: false,
494
+ message: 'SSH_HOST is required — set it in .env to your server IP or hostname.',
495
+ };
496
+ }
497
+ const result = await testSshHostReachability(String(host), sshPort);
498
+ return {
499
+ name: 'SSH host reachability',
500
+ pass: result.ok,
501
+ message: result.message,
502
+ };
503
+ })
504
+ );
505
+
506
+ checks.push(
507
+ await runCheck('Remote Docker daemon reachable', async () => {
508
+ const probe = await probeRemoteDockerPs(target);
509
+ if (!probe.sshOk) {
510
+ return {
511
+ name: 'Remote Docker daemon reachable',
512
+ pass: false,
513
+ message: formatRemoteDockerSshFailure(host, user),
514
+ };
515
+ }
516
+ if (probe.kind === 'not-installed') {
517
+ return {
518
+ name: 'Remote Docker daemon reachable',
519
+ pass: false,
520
+ message: formatRemoteDockerNotInstalled(host, user),
521
+ };
522
+ }
523
+ if (probe.kind === 'ok' || probe.kind === 'permission') {
524
+ return {
525
+ name: 'Remote Docker daemon reachable',
526
+ pass: true,
527
+ message: formatRemoteDockerDaemonOk(host, user),
528
+ };
529
+ }
530
+ return {
531
+ name: 'Remote Docker daemon reachable',
460
532
  pass: false,
461
- message: `Docker not reachable — ${msg}. Install Docker or set DOCKER_HOST for a remote daemon.`,
533
+ message:
534
+ probe.detail ||
535
+ `Remote Docker daemon is not reachable (${user}@${host}).`,
462
536
  };
463
- }
464
- })
465
- );
537
+ })
538
+ );
539
+
540
+ checks.push(
541
+ await runCheck('Remote Docker permission', async () => {
542
+ const probe = await probeRemoteDockerPs(target);
543
+ if (!probe.sshOk) {
544
+ return {
545
+ name: 'Remote Docker permission',
546
+ pass: false,
547
+ message: formatRemoteDockerSshFailure(host, user),
548
+ };
549
+ }
550
+ if (probe.kind === 'permission') {
551
+ return {
552
+ name: 'Remote Docker permission',
553
+ pass: false,
554
+ message: formatRemoteDockerPermissionDenied(host, user),
555
+ };
556
+ }
557
+ if (probe.kind === 'ok') {
558
+ return {
559
+ name: 'Remote Docker permission',
560
+ pass: true,
561
+ message: `SSH user '${user}' can access the Docker daemon on ${host}`,
562
+ };
563
+ }
564
+ if (probe.kind === 'not-installed') {
565
+ return {
566
+ name: 'Remote Docker permission',
567
+ pass: false,
568
+ message: formatRemoteDockerNotInstalled(host, user),
569
+ };
570
+ }
571
+ return {
572
+ name: 'Remote Docker permission',
573
+ pass: false,
574
+ message:
575
+ probe.detail ||
576
+ `Cannot verify Docker permissions for '${user}' on ${host}.`,
577
+ };
578
+ })
579
+ );
580
+ } else {
581
+ checks.push(
582
+ await runCheck('Docker daemon', async () => {
583
+ try {
584
+ const provider = getDeploymentProvider('docker', config, envName);
585
+ await provider.testConnection();
586
+ return { name: 'Docker daemon', pass: true, message: 'Docker daemon reachable' };
587
+ } catch (err) {
588
+ const msg = err instanceof Error ? err.message : String(err);
589
+ return {
590
+ name: 'Docker daemon',
591
+ pass: false,
592
+ message: `Docker not reachable — ${msg}. Install Docker or set DOCKER_HOST for a remote daemon.`,
593
+ };
594
+ }
595
+ })
596
+ );
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
+ }
466
614
  }
467
615
 
468
616
  if (deployType === 'kubernetes') {
@@ -1142,7 +1290,9 @@ export function registerDoctorCommand(program) {
1142
1290
  const method = getEnvMethod(env);
1143
1291
  if (!method) continue;
1144
1292
  // Local/.env uses unprefixed names; prefixed CI names are checked separately below.
1145
- required.push(...getDeploymentSecretKeys(method, config));
1293
+ required.push(
1294
+ ...getDeploymentSecretKeys(method, config, getEnvSettings(env))
1295
+ );
1146
1296
  }
1147
1297
 
1148
1298
  const unique = [...new Set(required)];
@@ -1179,7 +1329,7 @@ export function registerDoctorCommand(program) {
1179
1329
  config,
1180
1330
  config.environments
1181
1331
  );
1182
- const baseKeys = getDeploymentSecretKeys(method, config);
1332
+ const baseKeys = getDeploymentSecretKeys(method, config, getEnvSettings(env));
1183
1333
 
1184
1334
  for (let i = 0; i < baseKeys.length; i++) {
1185
1335
  const base = baseKeys[i];
@@ -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
  };
@@ -54,6 +54,16 @@ const MethodConfigSchema = z
54
54
  dockerImageName: z.string().optional(),
55
55
  dockerRegistryUrl: z.string().optional(),
56
56
  dockerHost: z.string().optional(),
57
+ /**
58
+ * Docker-method only. Kubernetes must ignore this — it deploys via kubectl,
59
+ * never a remote Docker daemon. `ssh` = node-ssh + remote docker CLI;
60
+ * `local` = this machine; `raw` = unmanaged DOCKER_HOST (ssh:// or tcp://).
61
+ */
62
+ remote: z
63
+ .object({
64
+ mode: z.enum(['ssh', 'local', 'raw']),
65
+ })
66
+ .optional(),
57
67
  healthCheckUrl: z.string().optional(),
58
68
  appName: z.string().optional(),
59
69
  framework: z.string().optional(),
@@ -266,6 +266,22 @@ export function resolveEnvTargets(config, envFlag) {
266
266
  return { targets: [envFlag], skippedDisabled: [] };
267
267
  }
268
268
 
269
+ /**
270
+ * Whitelist of method-config fields copied onto process env for Docker and
271
+ * Kubernetes providers. Structural (not a per-method if): only these keys are
272
+ * ever copied. `remote` / `host` / `user` / `keyPath` are intentionally absent
273
+ * — docker SSH identity is read by docker.js from settings + SSH_* env vars.
274
+ * Kubernetes uses this same helper and therefore cannot observe remote.mode.
275
+ */
276
+ export const METHOD_SETTINGS_ENV_OVERLAY = Object.freeze({
277
+ dockerImageName: 'DOCKER_IMAGE_NAME',
278
+ dockerRegistryUrl: 'DOCKER_REGISTRY_URL',
279
+ dockerHost: 'DOCKER_HOST',
280
+ kubeNamespace: 'KUBE_NAMESPACE',
281
+ kubeconfig: 'KUBECONFIG',
282
+ kubeContext: 'KUBE_CONTEXT',
283
+ });
284
+
269
285
  /**
270
286
  * Overlay method-specific config onto process env for Docker/K8s providers.
271
287
  * Secrets still come from real env vars; non-secret names/paths come from config.
@@ -277,12 +293,10 @@ export function resolveEnvTargets(config, envFlag) {
277
293
  export function mergeMethodSettingsIntoEnv(env, settings) {
278
294
  /** @type {Record<string, string|undefined>} */
279
295
  const out = { ...env };
280
- if (settings.dockerImageName) out.DOCKER_IMAGE_NAME = String(settings.dockerImageName);
281
- if (settings.dockerRegistryUrl) out.DOCKER_REGISTRY_URL = String(settings.dockerRegistryUrl);
282
- if (settings.dockerHost) out.DOCKER_HOST = String(settings.dockerHost);
283
- if (settings.kubeNamespace) out.KUBE_NAMESPACE = String(settings.kubeNamespace);
284
- if (settings.kubeconfig) out.KUBECONFIG = String(settings.kubeconfig);
285
- if (settings.kubeContext) out.KUBE_CONTEXT = String(settings.kubeContext);
296
+ for (const [settingKey, envKey] of Object.entries(METHOD_SETTINGS_ENV_OVERLAY)) {
297
+ const value = settings[settingKey];
298
+ if (value) out[envKey] = String(value);
299
+ }
286
300
  return out;
287
301
  }
288
302
 
@@ -302,4 +316,5 @@ export default {
302
316
  buildEnvironmentEntry,
303
317
  resolveEnvTargets,
304
318
  mergeMethodSettingsIntoEnv,
319
+ METHOD_SETTINGS_ENV_OVERLAY,
305
320
  };
@@ -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);