@akash-chowdhury-24/deployhub 2.0.31 → 2.0.32
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 +3 -2
- package/package.json +1 -1
- package/src/commands/doctor.js +147 -17
- package/src/core/config.js +10 -0
- package/src/core/environments.js +21 -6
- package/src/deployment/deployment-env.js +90 -24
- package/src/deployment/init-prompts.js +100 -6
- package/src/deployment/providers/docker.js +108 -5
- package/src/deployment/providers/kubernetes.js +3 -0
- package/src/deployment/providers/ssh.js +12 -87
- package/src/deployment/ssh-connection.js +149 -0
- package/src/utils/credential-inventory.js +10 -0
- package/src/utils/docker-image-deploy.js +4 -0
- package/src/utils/docker-remote-mode.js +33 -0
- package/src/utils/docker-remote.js +214 -0
- package/src/utils/github-actions.js +6 -1
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
|
|
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` |
|
|
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
package/src/commands/doctor.js
CHANGED
|
@@ -31,6 +31,15 @@ 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';
|
|
34
43
|
import {
|
|
35
44
|
buildPhpFpmUnitListCommand,
|
|
36
45
|
formatPhpFpmMissingError,
|
|
@@ -183,14 +192,16 @@ export async function runDeploymentChecks(config, envName, envConfig) {
|
|
|
183
192
|
|
|
184
193
|
/** @type {CheckResult[]} */
|
|
185
194
|
const checks = [];
|
|
186
|
-
const requiredKeys = getDeploymentEnvKeys(deployType, config);
|
|
195
|
+
const requiredKeys = getDeploymentEnvKeys(deployType, config, settings);
|
|
187
196
|
|
|
188
197
|
checks.push(
|
|
189
198
|
await runCheck(`${deployType} env vars`, async () => {
|
|
190
199
|
const missing = requiredKeys.filter((k) => {
|
|
191
200
|
if (k === 'SSH_KEY_PATH') {
|
|
192
|
-
return !process.env.SSH_KEY_PATH && !process.env.SSH_KEY;
|
|
201
|
+
return !process.env.SSH_KEY_PATH && !process.env.SSH_KEY && !settings.keyPath;
|
|
193
202
|
}
|
|
203
|
+
if (k === 'SSH_HOST') return !(settings.host || process.env.SSH_HOST);
|
|
204
|
+
if (k === 'SSH_USER') return !(settings.user || process.env.SSH_USER);
|
|
194
205
|
return !process.env[k];
|
|
195
206
|
});
|
|
196
207
|
if (missing.length > 0) {
|
|
@@ -447,22 +458,139 @@ export async function runDeploymentChecks(config, envName, envConfig) {
|
|
|
447
458
|
}
|
|
448
459
|
|
|
449
460
|
if (deployType === 'docker') {
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
461
|
+
const dockerRemoteMode = resolveDockerRemoteMode(settings, process.env);
|
|
462
|
+
|
|
463
|
+
if (dockerRemoteMode === 'ssh') {
|
|
464
|
+
const target = resolveDockerSshTarget(settings, process.env);
|
|
465
|
+
const host = target.host;
|
|
466
|
+
const user = target.user;
|
|
467
|
+
const keyPath = target.keyPath;
|
|
468
|
+
const sshPort = target.sshPort;
|
|
469
|
+
|
|
470
|
+
checks.push(
|
|
471
|
+
await runCheck('SSH key', async () => {
|
|
472
|
+
const result = await validateSshKeyForDoctor(
|
|
473
|
+
keyPath ? String(keyPath) : undefined,
|
|
474
|
+
process.env.SSH_KEY
|
|
475
|
+
);
|
|
476
|
+
return {
|
|
477
|
+
name: 'SSH key',
|
|
478
|
+
pass: result.ok,
|
|
479
|
+
message: result.message,
|
|
480
|
+
};
|
|
481
|
+
})
|
|
482
|
+
);
|
|
483
|
+
|
|
484
|
+
checks.push(
|
|
485
|
+
await runCheck('SSH host reachability', async () => {
|
|
486
|
+
if (!host) {
|
|
487
|
+
return {
|
|
488
|
+
name: 'SSH host reachability',
|
|
489
|
+
pass: false,
|
|
490
|
+
message: 'SSH_HOST is required — set it in .env to your server IP or hostname.',
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
const result = await testSshHostReachability(String(host), sshPort);
|
|
494
|
+
return {
|
|
495
|
+
name: 'SSH host reachability',
|
|
496
|
+
pass: result.ok,
|
|
497
|
+
message: result.message,
|
|
498
|
+
};
|
|
499
|
+
})
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
checks.push(
|
|
503
|
+
await runCheck('Remote Docker daemon reachable', async () => {
|
|
504
|
+
const probe = await probeRemoteDockerPs(target);
|
|
505
|
+
if (!probe.sshOk) {
|
|
506
|
+
return {
|
|
507
|
+
name: 'Remote Docker daemon reachable',
|
|
508
|
+
pass: false,
|
|
509
|
+
message: formatRemoteDockerSshFailure(host, user),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
if (probe.kind === 'not-installed') {
|
|
513
|
+
return {
|
|
514
|
+
name: 'Remote Docker daemon reachable',
|
|
515
|
+
pass: false,
|
|
516
|
+
message: formatRemoteDockerNotInstalled(host, user),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
if (probe.kind === 'ok' || probe.kind === 'permission') {
|
|
520
|
+
return {
|
|
521
|
+
name: 'Remote Docker daemon reachable',
|
|
522
|
+
pass: true,
|
|
523
|
+
message: formatRemoteDockerDaemonOk(host, user),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
name: 'Remote Docker daemon reachable',
|
|
528
|
+
pass: false,
|
|
529
|
+
message:
|
|
530
|
+
probe.detail ||
|
|
531
|
+
`Remote Docker daemon is not reachable (${user}@${host}).`,
|
|
532
|
+
};
|
|
533
|
+
})
|
|
534
|
+
);
|
|
535
|
+
|
|
536
|
+
checks.push(
|
|
537
|
+
await runCheck('Remote Docker permission', async () => {
|
|
538
|
+
const probe = await probeRemoteDockerPs(target);
|
|
539
|
+
if (!probe.sshOk) {
|
|
540
|
+
return {
|
|
541
|
+
name: 'Remote Docker permission',
|
|
542
|
+
pass: false,
|
|
543
|
+
message: formatRemoteDockerSshFailure(host, user),
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
if (probe.kind === 'permission') {
|
|
547
|
+
return {
|
|
548
|
+
name: 'Remote Docker permission',
|
|
549
|
+
pass: false,
|
|
550
|
+
message: formatRemoteDockerPermissionDenied(host, user),
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
if (probe.kind === 'ok') {
|
|
554
|
+
return {
|
|
555
|
+
name: 'Remote Docker permission',
|
|
556
|
+
pass: true,
|
|
557
|
+
message: `SSH user '${user}' can access the Docker daemon on ${host}`,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
if (probe.kind === 'not-installed') {
|
|
561
|
+
return {
|
|
562
|
+
name: 'Remote Docker permission',
|
|
563
|
+
pass: false,
|
|
564
|
+
message: formatRemoteDockerNotInstalled(host, user),
|
|
565
|
+
};
|
|
566
|
+
}
|
|
458
567
|
return {
|
|
459
|
-
name: 'Docker
|
|
568
|
+
name: 'Remote Docker permission',
|
|
460
569
|
pass: false,
|
|
461
|
-
message:
|
|
570
|
+
message:
|
|
571
|
+
probe.detail ||
|
|
572
|
+
`Cannot verify Docker permissions for '${user}' on ${host}.`,
|
|
462
573
|
};
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
|
|
574
|
+
})
|
|
575
|
+
);
|
|
576
|
+
} else {
|
|
577
|
+
checks.push(
|
|
578
|
+
await runCheck('Docker daemon', async () => {
|
|
579
|
+
try {
|
|
580
|
+
const provider = getDeploymentProvider('docker', config, envName);
|
|
581
|
+
await provider.testConnection();
|
|
582
|
+
return { name: 'Docker daemon', pass: true, message: 'Docker daemon reachable' };
|
|
583
|
+
} catch (err) {
|
|
584
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
585
|
+
return {
|
|
586
|
+
name: 'Docker daemon',
|
|
587
|
+
pass: false,
|
|
588
|
+
message: `Docker not reachable — ${msg}. Install Docker or set DOCKER_HOST for a remote daemon.`,
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
})
|
|
592
|
+
);
|
|
593
|
+
}
|
|
466
594
|
}
|
|
467
595
|
|
|
468
596
|
if (deployType === 'kubernetes') {
|
|
@@ -1142,7 +1270,9 @@ export function registerDoctorCommand(program) {
|
|
|
1142
1270
|
const method = getEnvMethod(env);
|
|
1143
1271
|
if (!method) continue;
|
|
1144
1272
|
// Local/.env uses unprefixed names; prefixed CI names are checked separately below.
|
|
1145
|
-
required.push(
|
|
1273
|
+
required.push(
|
|
1274
|
+
...getDeploymentSecretKeys(method, config, getEnvSettings(env))
|
|
1275
|
+
);
|
|
1146
1276
|
}
|
|
1147
1277
|
|
|
1148
1278
|
const unique = [...new Set(required)];
|
|
@@ -1179,7 +1309,7 @@ export function registerDoctorCommand(program) {
|
|
|
1179
1309
|
config,
|
|
1180
1310
|
config.environments
|
|
1181
1311
|
);
|
|
1182
|
-
const baseKeys = getDeploymentSecretKeys(method, config);
|
|
1312
|
+
const baseKeys = getDeploymentSecretKeys(method, config, getEnvSettings(env));
|
|
1183
1313
|
|
|
1184
1314
|
for (let i = 0; i < baseKeys.length; i++) {
|
|
1185
1315
|
const base = baseKeys[i];
|
package/src/core/config.js
CHANGED
|
@@ -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(),
|
package/src/core/environments.js
CHANGED
|
@@ -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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
};
|
|
@@ -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:
|
|
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
|
-
'
|
|
137
|
-
'
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
-
'
|
|
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
|
|
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
|
},
|
|
@@ -161,6 +161,7 @@ function buildNonInteractiveDeployAnswers(
|
|
|
161
161
|
dockerRegistryUsername: '',
|
|
162
162
|
dockerRegistryToken: '',
|
|
163
163
|
dockerHost: '',
|
|
164
|
+
remoteMode: 'local',
|
|
164
165
|
healthUrl: '',
|
|
165
166
|
};
|
|
166
167
|
}
|
|
@@ -307,7 +308,7 @@ async function promptKubernetesDeployment(base, projectName, projectType, option
|
|
|
307
308
|
* @param {'frontend'|'backend'|'both'} projectType
|
|
308
309
|
*/
|
|
309
310
|
async function promptDockerDeployment(base, projectName, projectType) {
|
|
310
|
-
const
|
|
311
|
+
const imageAnswers = await inquirer.prompt([
|
|
311
312
|
{
|
|
312
313
|
type: 'input',
|
|
313
314
|
name: 'dockerImageName',
|
|
@@ -329,11 +330,81 @@ async function promptDockerDeployment(base, projectName, projectType) {
|
|
|
329
330
|
name: 'dockerRegistryToken',
|
|
330
331
|
message: 'Registry token/password (only if using a private registry):',
|
|
331
332
|
},
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
const { remoteMode } = await inquirer.prompt([
|
|
332
336
|
{
|
|
333
|
-
type: '
|
|
334
|
-
name: '
|
|
335
|
-
message: '
|
|
337
|
+
type: 'list',
|
|
338
|
+
name: 'remoteMode',
|
|
339
|
+
message: 'Where should the container run?',
|
|
340
|
+
choices: [
|
|
341
|
+
{ name: 'Locally (this machine or CI runner)', value: 'local' },
|
|
342
|
+
{
|
|
343
|
+
name: 'Remote Linux server via SSH (recommended for production)',
|
|
344
|
+
value: 'ssh',
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
name: 'Advanced: raw Docker host URI (tcp://, custom SSH setup)',
|
|
348
|
+
value: 'raw',
|
|
349
|
+
},
|
|
350
|
+
],
|
|
351
|
+
default: 'local',
|
|
336
352
|
},
|
|
353
|
+
]);
|
|
354
|
+
|
|
355
|
+
/** @type {Record<string, string>} */
|
|
356
|
+
let sshAnswers = {};
|
|
357
|
+
/** @type {Record<string, string>} */
|
|
358
|
+
let rawAnswers = {};
|
|
359
|
+
|
|
360
|
+
if (remoteMode === 'ssh') {
|
|
361
|
+
sshAnswers = await inquirer.prompt([
|
|
362
|
+
{
|
|
363
|
+
type: 'input',
|
|
364
|
+
name: 'host',
|
|
365
|
+
message: 'Remote server host/IP:',
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
type: 'input',
|
|
369
|
+
name: 'user',
|
|
370
|
+
message: 'SSH username:',
|
|
371
|
+
},
|
|
372
|
+
{
|
|
373
|
+
type: 'input',
|
|
374
|
+
name: 'keyPath',
|
|
375
|
+
message: 'Path to SSH private key:',
|
|
376
|
+
},
|
|
377
|
+
]);
|
|
378
|
+
|
|
379
|
+
await runSshInitValidation({
|
|
380
|
+
host: sshAnswers.host,
|
|
381
|
+
user: sshAnswers.user,
|
|
382
|
+
keyPath: sshAnswers.keyPath,
|
|
383
|
+
sshPort: 22,
|
|
384
|
+
deployType: getDeployTypeLabel('docker'),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (remoteMode === 'raw') {
|
|
389
|
+
console.log(
|
|
390
|
+
chalk.gray(
|
|
391
|
+
'\n Note: this mode uses Docker\'s native ssh://tcp:// transport and\n' +
|
|
392
|
+
' depends on your local machine\'s own SSH/TLS configuration —\n' +
|
|
393
|
+
' DeployHub cannot validate this connection ahead of time. Prefer\n' +
|
|
394
|
+
' "Remote Linux server via SSH" above unless you have a specific\n' +
|
|
395
|
+
' reason to use this.\n'
|
|
396
|
+
)
|
|
397
|
+
);
|
|
398
|
+
rawAnswers = await inquirer.prompt([
|
|
399
|
+
{
|
|
400
|
+
type: 'input',
|
|
401
|
+
name: 'dockerHost',
|
|
402
|
+
message: 'Remote Docker host (optional, e.g. ssh://ubuntu@203.0.113.10):',
|
|
403
|
+
},
|
|
404
|
+
]);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const { healthUrl } = await inquirer.prompt([
|
|
337
408
|
{
|
|
338
409
|
type: 'input',
|
|
339
410
|
name: 'healthUrl',
|
|
@@ -341,7 +412,14 @@ async function promptDockerDeployment(base, projectName, projectType) {
|
|
|
341
412
|
},
|
|
342
413
|
]);
|
|
343
414
|
|
|
344
|
-
return {
|
|
415
|
+
return {
|
|
416
|
+
...base,
|
|
417
|
+
...imageAnswers,
|
|
418
|
+
remoteMode,
|
|
419
|
+
...sshAnswers,
|
|
420
|
+
dockerHost: rawAnswers.dockerHost || '',
|
|
421
|
+
healthUrl,
|
|
422
|
+
};
|
|
345
423
|
}
|
|
346
424
|
|
|
347
425
|
/**
|
|
@@ -563,7 +641,17 @@ export function buildServerEnvEntry(
|
|
|
563
641
|
if (deployAnswers.deployType === 'docker') {
|
|
564
642
|
settings.dockerImageName = deployAnswers.dockerImageName || projectName;
|
|
565
643
|
settings.dockerRegistryUrl = deployAnswers.dockerRegistryUrl || '';
|
|
566
|
-
|
|
644
|
+
const remoteMode =
|
|
645
|
+
deployAnswers.remoteMode || (deployAnswers.dockerHost ? 'raw' : 'local');
|
|
646
|
+
settings.remote = { mode: remoteMode };
|
|
647
|
+
// Raw URI stays in config (existing DOCKER_HOST overlay). SSH key path is
|
|
648
|
+
// env-only. Host/user are non-secret settings (same as ec2) so doctor and
|
|
649
|
+
// .env.example can resolve them without writing the private key path here.
|
|
650
|
+
settings.dockerHost = remoteMode === 'raw' ? deployAnswers.dockerHost || '' : '';
|
|
651
|
+
if (remoteMode === 'ssh') {
|
|
652
|
+
if (deployAnswers.host) settings.host = deployAnswers.host;
|
|
653
|
+
if (deployAnswers.user) settings.user = deployAnswers.user;
|
|
654
|
+
}
|
|
567
655
|
return {
|
|
568
656
|
enabled: true,
|
|
569
657
|
method: 'docker',
|
|
@@ -680,6 +768,12 @@ export function getDockerEnvSecrets(deployAnswers) {
|
|
|
680
768
|
if (deployAnswers.dockerRegistryToken) {
|
|
681
769
|
vars.DOCKER_REGISTRY_TOKEN = deployAnswers.dockerRegistryToken;
|
|
682
770
|
}
|
|
771
|
+
if (deployAnswers.deployType === 'docker' && deployAnswers.remoteMode === 'ssh') {
|
|
772
|
+
if (deployAnswers.keyPath) vars.SSH_KEY_PATH = deployAnswers.keyPath;
|
|
773
|
+
}
|
|
774
|
+
if (deployAnswers.deployType === 'docker' && deployAnswers.remoteMode === 'raw') {
|
|
775
|
+
if (deployAnswers.dockerHost) vars.DOCKER_HOST = deployAnswers.dockerHost;
|
|
776
|
+
}
|
|
683
777
|
return Object.keys(vars).length > 0 ? vars : null;
|
|
684
778
|
}
|
|
685
779
|
|