@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.
@@ -3,6 +3,9 @@
3
3
  * and post-init next steps.
4
4
  */
5
5
 
6
+ import { getEnvSettings } from '../core/environments.js';
7
+ import { resolveDockerRemoteMode } from '../utils/docker-remote-mode.js';
8
+
6
9
  /** @typedef {{ key: string, comment: string[], example?: string, default?: string, optionalReason?: string, when?: 'backend'|'optional'|'ci' }} EnvVarDef */
7
10
 
8
11
  /** @type {Record<string, EnvVarDef[]>} */
@@ -131,10 +134,12 @@ export const DEPLOYMENT_ENV_DEFS = {
131
134
  },
132
135
  {
133
136
  key: 'DOCKER_HOST',
134
- optionalReason: 'only required when deploying to a remote Docker daemon instead of local Docker',
137
+ optionalReason:
138
+ 'only for advanced raw Docker CLI transport (tcp:// or custom ssh://). Prefer remote.mode "ssh" (SSH_HOST / SSH_USER / SSH_KEY_PATH) for a remote Linux box',
135
139
  comment: [
136
- 'Remote Docker daemon address.',
137
- 'Examples: ssh://ubuntu@203.0.113.10 | tcp://203.0.113.10:2376',
140
+ 'Advanced/escape-hatch: raw Docker daemon URI. DeployHub cannot validate ssh:// via doctor.',
141
+ 'Prefer "Remote Linux server via SSH" at init (remote.mode: ssh) unless you manage TLS/ssh:// yourself.',
142
+ 'Examples: tcp://203.0.113.10:2376 | ssh://ubuntu@203.0.113.10',
138
143
  ],
139
144
  when: 'optional',
140
145
  },
@@ -394,6 +399,56 @@ export const DEPLOYMENT_ENV_KEYS = Object.fromEntries(
394
399
  ])
395
400
  );
396
401
 
402
+ const DOCKER_HOST_KEYS = new Set(['DOCKER_HOST', 'DOCKER_TLS_VERIFY', 'DOCKER_CERT_PATH']);
403
+
404
+ /**
405
+ * SSH identity vars used when docker `remote.mode === "ssh"`.
406
+ * Same names as ec2; per-env secret prefixing separates a sibling ssh/ec2 env.
407
+ * Not listed in static DEPLOYMENT_ENV_KEYS.docker (local/raw docker do not read them).
408
+ */
409
+ export const DOCKER_SSH_ENV_VARS = [
410
+ ...SSH_BASE_ENV_VARS.filter((d) => d.key !== 'SSH_DEPLOY_PATH'),
411
+ ...SSH_CI_ENV_VARS,
412
+ ];
413
+
414
+ /**
415
+ * @param {Record<string, unknown>|null|undefined} settings
416
+ * @returns {boolean}
417
+ */
418
+ function dockerHasExplicitRemoteMode(settings) {
419
+ const remote = settings && /** @type {Record<string, unknown>} */ (settings).remote;
420
+ const mode =
421
+ remote && typeof remote === 'object'
422
+ ? /** @type {Record<string, unknown>} */ (remote).mode
423
+ : undefined;
424
+ return mode === 'ssh' || mode === 'local' || mode === 'raw';
425
+ }
426
+
427
+ /**
428
+ * Per-env docker defs: ssh mode adds SSH_* and drops raw DOCKER_HOST;
429
+ * explicit local drops DOCKER_HOST; configs with no remote.mode keep legacy defs.
430
+ *
431
+ * @param {string} deployType
432
+ * @param {Record<string, unknown>|null} [settings]
433
+ * @returns {EnvVarDef[]}
434
+ */
435
+ export function getMethodEnvDefs(deployType, settings = null) {
436
+ const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
437
+ if (deployType !== 'docker') return defs;
438
+ const s = settings || {};
439
+ if (!dockerHasExplicitRemoteMode(s)) {
440
+ return defs;
441
+ }
442
+ const mode = resolveDockerRemoteMode(s, {});
443
+ if (mode === 'ssh') {
444
+ return [...defs.filter((d) => !DOCKER_HOST_KEYS.has(d.key)), ...DOCKER_SSH_ENV_VARS];
445
+ }
446
+ if (mode === 'local') {
447
+ return defs.filter((d) => !DOCKER_HOST_KEYS.has(d.key));
448
+ }
449
+ return defs;
450
+ }
451
+
397
452
  /**
398
453
  * Deployment-side cloud-API lookup credentials — distinct from storage-provider
399
454
  * env vars (storage stays project-wide / unprefixed).
@@ -422,8 +477,8 @@ export const DEPLOYMENT_LOOKUP_ENV_KEYS = new Set([
422
477
  * @param {import('../core/config.js').DeployHubConfig} [config]
423
478
  * @returns {string[]}
424
479
  */
425
- export function getDeploymentEnvKeys(deployType, config = null) {
426
- const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
480
+ export function getDeploymentEnvKeys(deployType, config = null, settings = null) {
481
+ const defs = getMethodEnvDefs(deployType, settings);
427
482
  const projectType = config?.projectType || 'frontend';
428
483
  const isBackend = projectType === 'backend' || projectType === 'both';
429
484
 
@@ -452,8 +507,8 @@ function toGithubSecretKey(key) {
452
507
  * @param {import('../core/config.js').DeployHubConfig} [config]
453
508
  * @returns {string[]}
454
509
  */
455
- export function getDeploymentSecretKeys(deployType, config = null) {
456
- const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
510
+ export function getDeploymentSecretKeys(deployType, config = null, settings = null) {
511
+ const defs = getMethodEnvDefs(deployType, settings);
457
512
  const projectType = config?.projectType || 'frontend';
458
513
  const isBackend = projectType === 'backend' || projectType === 'both';
459
514
 
@@ -477,8 +532,8 @@ export function getDeploymentSecretKeys(deployType, config = null) {
477
532
  * @param {import('../core/config.js').DeployHubConfig} [config]
478
533
  * @returns {string[]}
479
534
  */
480
- export function getDeploymentWorkflowSecretKeys(deployType, config = null) {
481
- const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
535
+ export function getDeploymentWorkflowSecretKeys(deployType, config = null, settings = null) {
536
+ const defs = getMethodEnvDefs(deployType, settings);
482
537
  const projectType = config?.projectType || 'frontend';
483
538
  const isBackend = projectType === 'backend' || projectType === 'both';
484
539
 
@@ -503,8 +558,8 @@ export function getDeploymentWorkflowSecretKeys(deployType, config = null) {
503
558
  * @param {import('../core/config.js').DeployHubConfig} [config]
504
559
  * @returns {SecretChecklistItem[]}
505
560
  */
506
- export function getDeploymentSecretChecklistItems(deployType, config = null) {
507
- const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
561
+ export function getDeploymentSecretChecklistItems(deployType, config = null, settings = null) {
562
+ const defs = getMethodEnvDefs(deployType, settings);
508
563
  const projectType = config?.projectType || 'frontend';
509
564
  const isBackend = projectType === 'backend' || projectType === 'both';
510
565
 
@@ -651,7 +706,11 @@ export function applyEnvSecretOverlay(envName, config, env = process.env) {
651
706
  null;
652
707
  if (!method) return out;
653
708
 
654
- const keys = getDeploymentWorkflowSecretKeys(method, /** @type {any} */ (config));
709
+ const keys = getDeploymentWorkflowSecretKeys(
710
+ method,
711
+ /** @type {any} */ (config),
712
+ getEnvSettings(entry)
713
+ );
655
714
  for (const key of keys) {
656
715
  const prefixed = prefixSecretKey(envName, key);
657
716
  if (out[prefixed]) {
@@ -680,10 +739,12 @@ export function getDeploymentWorkflowSecretKeysForEnv(
680
739
  config = null,
681
740
  environments = null
682
741
  ) {
683
- const keys = getDeploymentWorkflowSecretKeys(deployType, config);
742
+ const envs = environments || config?.environments || {};
743
+ const settings = getEnvSettings(envs[envName]);
744
+ const keys = getDeploymentWorkflowSecretKeys(deployType, config, settings);
684
745
  const cfg = {
685
746
  ...(config || {}),
686
- environments: environments || config?.environments || {},
747
+ environments: envs,
687
748
  };
688
749
  if (!envUsesPrefixedSecrets(envName, cfg)) {
689
750
  return keys;
@@ -705,7 +766,9 @@ export function getDeploymentSecretChecklistItemsForEnv(
705
766
  config = null,
706
767
  environments = null
707
768
  ) {
708
- const items = getDeploymentSecretChecklistItems(deployType, config);
769
+ const envs = environments || config?.environments || {};
770
+ const settings = getEnvSettings(envs[envName]);
771
+ const items = getDeploymentSecretChecklistItems(deployType, config, settings);
709
772
  const cfg = {
710
773
  ...(config || {}),
711
774
  environments: environments || config?.environments || {},
@@ -736,10 +799,12 @@ export function getDeploymentSecretKeysForEnv(
736
799
  config = null,
737
800
  environments = null
738
801
  ) {
739
- const keys = getDeploymentSecretKeys(deployType, config);
802
+ const envs = environments || config?.environments || {};
803
+ const settings = getEnvSettings(envs[envName]);
804
+ const keys = getDeploymentSecretKeys(deployType, config, settings);
740
805
  const cfg = {
741
806
  ...(config || {}),
742
- environments: environments || config?.environments || {},
807
+ environments: envs,
743
808
  };
744
809
  if (!envUsesPrefixedSecrets(envName, cfg)) {
745
810
  return keys;
@@ -786,7 +851,6 @@ export function generateDeploymentEnvSection(
786
851
  environments = {},
787
852
  options = {}
788
853
  ) {
789
- const defs = DEPLOYMENT_ENV_DEFS[deployType] || [];
790
854
  const projectType = config?.projectType || 'frontend';
791
855
  const isBackend = projectType === 'backend' || projectType === 'both';
792
856
  const envName = options.envName;
@@ -819,7 +883,7 @@ export function generateDeploymentEnvSection(
819
883
  ? /** @type {Record<string, unknown>} */ (envEntry.config)
820
884
  : envEntry;
821
885
 
822
- for (const d of defs) {
886
+ for (const d of getMethodEnvDefs(deployType, settings)) {
823
887
  if (d.when === 'backend' && !isBackend) continue;
824
888
 
825
889
  const isOptional = d.when === 'optional' || d.when === 'ci';
@@ -907,23 +971,25 @@ export const DEPLOYMENT_GUIDE = {
907
971
  },
908
972
  docker: {
909
973
  before: [
910
- 'Docker installed locally (docker --version works).',
911
- 'If deploying to a remote host: Docker installed on that host and reachable.',
974
+ 'Docker installed locally (docker --version works) — used to build/push images.',
975
+ 'For remote Linux via SSH: Docker installed on that host and the SSH user in the docker group.',
912
976
  'A Dockerfile or docker-compose.yml in your project (or enable pipeline.docker).',
913
977
  'Registry account if pushing to a private registry (Docker Hub, GHCR, etc.).',
914
978
  ],
915
979
  automates: [
916
980
  'Generates config, workflow, and .env.example for registry and image settings.',
917
981
  'Generates a starter Dockerfile and .dockerignore when missing.',
918
- 'Tests Docker daemon connectivity during init.',
982
+ 'Offers local Docker, first-class remote SSH (node-ssh), or advanced raw DOCKER_HOST.',
983
+ 'Validates SSH key and host when remote.mode is ssh; tests the local daemon otherwise.',
919
984
  'Builds the image once during the pipeline docker stage, then reuses it on deploy.',
920
985
  ],
921
986
  after: [
922
987
  'Copy .env.example to .env and set DOCKER_IMAGE_NAME (required — e.g. myuser/myapp).',
923
- 'Optional in .env: DOCKER_IMAGE_TAG, DOCKER_REGISTRY_URL, DOCKER_HOST, DOCKER_TLS_VERIFY, DOCKER_CERT_PATH.',
988
+ 'Remote Linux via SSH: also set SSH_HOST, SSH_USER, SSH_KEY_PATH (same names as EC2).',
989
+ 'Advanced raw URI only: DOCKER_HOST, and DOCKER_TLS_VERIFY / DOCKER_CERT_PATH for tcp:// TLS.',
924
990
  'If using a private registry: also set DOCKER_REGISTRY_USERNAME and DOCKER_REGISTRY_TOKEN.',
925
991
  'Add the GitHub Secrets listed below (Settings → Secrets and variables → Actions). Local .env is NOT used by GitHub Actions — doctor only checks your machine.',
926
- 'Run deployhub doctor to verify Docker is reachable.',
992
+ 'Run deployhub doctor to verify Docker (and SSH, when remote.mode is ssh).',
927
993
  'git push origin main to trigger your first deployment.',
928
994
  ],
929
995
  },
@@ -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);
@@ -161,6 +166,7 @@ function buildNonInteractiveDeployAnswers(
161
166
  dockerRegistryUsername: '',
162
167
  dockerRegistryToken: '',
163
168
  dockerHost: '',
169
+ remoteMode: 'local',
164
170
  healthUrl: '',
165
171
  };
166
172
  }
@@ -305,9 +311,13 @@ async function promptKubernetesDeployment(base, projectName, projectType, option
305
311
  * @param {Record<string, string>} base
306
312
  * @param {string} projectName
307
313
  * @param {'frontend'|'backend'|'both'} projectType
314
+ * @param {{
315
+ * backendConfig?: Record<string, unknown>|null,
316
+ * portDefault?: number,
317
+ * }} [options]
308
318
  */
309
- async function promptDockerDeployment(base, projectName, projectType) {
310
- const dockerAnswers = await inquirer.prompt([
319
+ async function promptDockerDeployment(base, projectName, projectType, options = {}) {
320
+ const imageAnswers = await inquirer.prompt([
311
321
  {
312
322
  type: 'input',
313
323
  name: 'dockerImageName',
@@ -329,10 +339,98 @@ async function promptDockerDeployment(base, projectName, projectType) {
329
339
  name: 'dockerRegistryToken',
330
340
  message: 'Registry token/password (only if using a private registry):',
331
341
  },
342
+ ]);
343
+
344
+ const { remoteMode } = await inquirer.prompt([
332
345
  {
333
- type: 'input',
334
- name: 'dockerHost',
335
- message: 'Remote Docker host (optional, e.g. ssh://ubuntu@203.0.113.10):',
346
+ type: 'list',
347
+ name: 'remoteMode',
348
+ message: 'Where should the container run?',
349
+ choices: [
350
+ { name: 'Locally (this machine or CI runner)', value: 'local' },
351
+ {
352
+ name: 'Remote Linux server via SSH (recommended for production)',
353
+ value: 'ssh',
354
+ },
355
+ {
356
+ name: 'Advanced: raw Docker host URI (tcp://, custom SSH setup)',
357
+ value: 'raw',
358
+ },
359
+ ],
360
+ default: 'local',
361
+ },
362
+ ]);
363
+
364
+ /** @type {Record<string, string>} */
365
+ let sshAnswers = {};
366
+ /** @type {Record<string, string>} */
367
+ let rawAnswers = {};
368
+
369
+ if (remoteMode === 'ssh') {
370
+ sshAnswers = await inquirer.prompt([
371
+ {
372
+ type: 'input',
373
+ name: 'host',
374
+ message: 'Remote server host/IP:',
375
+ },
376
+ {
377
+ type: 'input',
378
+ name: 'user',
379
+ message: 'SSH username:',
380
+ },
381
+ {
382
+ type: 'input',
383
+ name: 'keyPath',
384
+ message: 'Path to SSH private key:',
385
+ },
386
+ ]);
387
+
388
+ await runSshInitValidation({
389
+ host: sshAnswers.host,
390
+ user: sshAnswers.user,
391
+ keyPath: sshAnswers.keyPath,
392
+ sshPort: 22,
393
+ deployType: getDeployTypeLabel('docker'),
394
+ });
395
+ }
396
+
397
+ if (remoteMode === 'raw') {
398
+ console.log(
399
+ chalk.gray(
400
+ '\n Note: this mode uses Docker\'s native ssh://tcp:// transport and\n' +
401
+ ' depends on your local machine\'s own SSH/TLS configuration —\n' +
402
+ ' DeployHub cannot validate this connection ahead of time. Prefer\n' +
403
+ ' "Remote Linux server via SSH" above unless you have a specific\n' +
404
+ ' reason to use this.\n'
405
+ )
406
+ );
407
+ rawAnswers = await inquirer.prompt([
408
+ {
409
+ type: 'input',
410
+ name: 'dockerHost',
411
+ message: 'Remote Docker host (optional, e.g. ssh://ubuntu@203.0.113.10):',
412
+ },
413
+ ]);
414
+ }
415
+
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
+ },
336
434
  },
337
435
  {
338
436
  type: 'input',
@@ -341,7 +439,15 @@ async function promptDockerDeployment(base, projectName, projectType) {
341
439
  },
342
440
  ]);
343
441
 
344
- return { ...base, ...dockerAnswers };
442
+ return {
443
+ ...base,
444
+ ...imageAnswers,
445
+ remoteMode,
446
+ ...sshAnswers,
447
+ dockerHost: rawAnswers.dockerHost || '',
448
+ port,
449
+ healthUrl,
450
+ };
345
451
  }
346
452
 
347
453
  /**
@@ -563,7 +669,26 @@ export function buildServerEnvEntry(
563
669
  if (deployAnswers.deployType === 'docker') {
564
670
  settings.dockerImageName = deployAnswers.dockerImageName || projectName;
565
671
  settings.dockerRegistryUrl = deployAnswers.dockerRegistryUrl || '';
566
- settings.dockerHost = deployAnswers.dockerHost || '';
672
+ const remoteMode =
673
+ deployAnswers.remoteMode || (deployAnswers.dockerHost ? 'raw' : 'local');
674
+ settings.remote = { mode: remoteMode };
675
+ // Raw URI stays in config (existing DOCKER_HOST overlay). SSH key path is
676
+ // env-only. Host/user are non-secret settings (same as ec2) so doctor and
677
+ // .env.example can resolve them without writing the private key path here.
678
+ settings.dockerHost = remoteMode === 'raw' ? deployAnswers.dockerHost || '' : '';
679
+ if (remoteMode === 'ssh') {
680
+ if (deployAnswers.host) settings.host = deployAnswers.host;
681
+ if (deployAnswers.user) settings.user = deployAnswers.user;
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
+ }
567
692
  return {
568
693
  enabled: true,
569
694
  method: 'docker',
@@ -680,6 +805,12 @@ export function getDockerEnvSecrets(deployAnswers) {
680
805
  if (deployAnswers.dockerRegistryToken) {
681
806
  vars.DOCKER_REGISTRY_TOKEN = deployAnswers.dockerRegistryToken;
682
807
  }
808
+ if (deployAnswers.deployType === 'docker' && deployAnswers.remoteMode === 'ssh') {
809
+ if (deployAnswers.keyPath) vars.SSH_KEY_PATH = deployAnswers.keyPath;
810
+ }
811
+ if (deployAnswers.deployType === 'docker' && deployAnswers.remoteMode === 'raw') {
812
+ if (deployAnswers.dockerHost) vars.DOCKER_HOST = deployAnswers.dockerHost;
813
+ }
683
814
  return Object.keys(vars).length > 0 ? vars : null;
684
815
  }
685
816