@akash-chowdhury-24/deployhub 2.0.14 → 2.0.16
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 +98 -25
- package/package.json +1 -1
- package/src/artifact/engine.js +11 -4
- package/src/cli/index.js +4 -0
- package/src/commands/deploy.js +61 -16
- package/src/commands/doctor.js +212 -26
- package/src/commands/env.js +326 -0
- package/src/commands/init.js +97 -64
- package/src/commands/rollback.js +84 -24
- package/src/commands/sync-k8s-ports.js +71 -0
- package/src/commands/verify.js +97 -33
- package/src/core/config.js +371 -33
- package/src/core/environments.js +300 -0
- package/src/core/stages.js +55 -19
- package/src/deployment/deployment-env.js +210 -0
- package/src/deployment/index.js +58 -7
- package/src/deployment/init-prompts.js +151 -46
- package/src/deployment/providers/azure-vm.js +8 -10
- package/src/deployment/providers/docker.js +10 -2
- package/src/deployment/providers/ec2.js +7 -9
- package/src/deployment/providers/gcp-vm.js +8 -10
- package/src/deployment/providers/kubernetes.js +15 -6
- package/src/deployment/providers/ssh.js +18 -13
- package/src/storage/index.js +230 -3
- package/src/utils/build-id.js +23 -0
- package/src/utils/dockerfile-expose.js +117 -0
- package/src/utils/github-actions.js +344 -31
- package/src/utils/health-check.js +158 -0
- package/src/utils/kubernetes-manifests.js +105 -10
- package/src/utils/nginx.js +66 -15
- package/src/utils/rollback/engine.js +147 -40
- package/src/utils/scaffold.js +8 -3
package/src/commands/doctor.js
CHANGED
|
@@ -4,14 +4,21 @@ import fs from 'fs-extra';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import os from 'os';
|
|
6
6
|
import axios from 'axios';
|
|
7
|
-
import { loadConfig, loadEnv } from '../core/config.js';
|
|
7
|
+
import { loadConfig, loadEnv, getEnvMethod, getEnvSettings } from '../core/config.js';
|
|
8
|
+
import {
|
|
9
|
+
isEnvEnabled,
|
|
10
|
+
resolveDefaultEnvironmentName,
|
|
11
|
+
} from '../core/environments.js';
|
|
12
|
+
import { loadEnvArtifactHistory } from '../storage/index.js';
|
|
8
13
|
import { testProvider } from '../storage/index.js';
|
|
9
14
|
import { getDeploymentProvider } from '../deployment/index.js';
|
|
10
|
-
import { PROVIDER_ENV_MAP, getRollbackWorkflowDoctorCheck } from '../utils/github-actions.js';
|
|
15
|
+
import { PROVIDER_ENV_MAP, getRollbackWorkflowDoctorCheck, getWorkflowDriftDoctorChecks } from '../utils/github-actions.js';
|
|
11
16
|
import { printDoctorFooter } from '../utils/author.js';
|
|
12
17
|
import { createLocalProvider } from '../storage/providers/local.js';
|
|
13
18
|
import {
|
|
14
19
|
getDeploymentEnvKeys,
|
|
20
|
+
getDeploymentSecretKeysForEnv,
|
|
21
|
+
envUsesPrefixedSecrets,
|
|
15
22
|
getDeploymentSecretKeys,
|
|
16
23
|
} from '../deployment/deployment-env.js';
|
|
17
24
|
import { testSshConnectivity, validateSshKeyForDoctor, testSshHostReachability } from '../deployment/init-helpers.js';
|
|
@@ -27,6 +34,62 @@ import { namespaceExists } from '../utils/kubernetes-namespace.js';
|
|
|
27
34
|
* @typedef {{ name: string, pass: boolean, message: string }} CheckResult
|
|
28
35
|
*/
|
|
29
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Whether doctor should `process.exit(1)` — only blocking (non-informational) failures count.
|
|
39
|
+
* @param {CheckResult[]} results
|
|
40
|
+
* @param {Set<string>} informationalCheckNames
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
export function doctorHasBlockingFailures(results, informationalCheckNames) {
|
|
44
|
+
const blocking = results.filter((r) => !informationalCheckNames.has(r.name));
|
|
45
|
+
return blocking.some((r) => !r.pass);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Run per-env deployment checks with try/catch isolation (one env crash does not abort others).
|
|
50
|
+
* Disabled env failures are marked informational when `includeDisabled` is true.
|
|
51
|
+
*
|
|
52
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
53
|
+
* @param {string[]} envNames
|
|
54
|
+
* @param {{ includeDisabled?: boolean, runChecks?: typeof runDeploymentChecks }} [options]
|
|
55
|
+
* @returns {Promise<{ results: CheckResult[], informationalCheckNames: Set<string> }>}
|
|
56
|
+
*/
|
|
57
|
+
export async function collectEnvDeploymentChecks(config, envNames, options = {}) {
|
|
58
|
+
const includeDisabled = options.includeDisabled === true;
|
|
59
|
+
const runChecks = options.runChecks || runDeploymentChecks;
|
|
60
|
+
/** @type {CheckResult[]} */
|
|
61
|
+
const results = [];
|
|
62
|
+
/** @type {Set<string>} */
|
|
63
|
+
const informationalCheckNames = new Set();
|
|
64
|
+
|
|
65
|
+
for (const envName of envNames) {
|
|
66
|
+
const env = config.environments?.[envName];
|
|
67
|
+
if (!env) continue;
|
|
68
|
+
const enabled = isEnvEnabled(env);
|
|
69
|
+
if (!enabled && !includeDisabled) continue;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const deployChecks = await runChecks(config, envName, env);
|
|
73
|
+
for (const c of deployChecks) {
|
|
74
|
+
c.name = `${envName}/${c.name}`;
|
|
75
|
+
results.push(c);
|
|
76
|
+
if (!enabled) informationalCheckNames.add(c.name);
|
|
77
|
+
}
|
|
78
|
+
} catch (err) {
|
|
79
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
80
|
+
const name = `${envName}/checks`;
|
|
81
|
+
results.push({
|
|
82
|
+
name,
|
|
83
|
+
pass: false,
|
|
84
|
+
message: `Check aborted: ${message}`,
|
|
85
|
+
});
|
|
86
|
+
if (!enabled) informationalCheckNames.add(name);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { results, informationalCheckNames };
|
|
91
|
+
}
|
|
92
|
+
|
|
30
93
|
/** @type {Set<string>} */
|
|
31
94
|
const NODE_FRAMEWORKS = new Set(['express', 'nestjs', 'fastify', 'koa', 'nextjs', 'node']);
|
|
32
95
|
/** @type {Set<string>} */
|
|
@@ -102,9 +165,10 @@ function needsNginxActivationForDeploy(config) {
|
|
|
102
165
|
* @param {Record<string, unknown>} envConfig
|
|
103
166
|
* @returns {Promise<CheckResult[]>}
|
|
104
167
|
*/
|
|
105
|
-
async function runDeploymentChecks(config, envName, envConfig) {
|
|
106
|
-
const deployType = envConfig
|
|
168
|
+
export async function runDeploymentChecks(config, envName, envConfig) {
|
|
169
|
+
const deployType = getEnvMethod(envConfig);
|
|
107
170
|
if (!deployType || typeof deployType !== 'string') return [];
|
|
171
|
+
const settings = getEnvSettings(envConfig);
|
|
108
172
|
|
|
109
173
|
/** @type {CheckResult[]} */
|
|
110
174
|
const checks = [];
|
|
@@ -134,10 +198,10 @@ async function runDeploymentChecks(config, envName, envConfig) {
|
|
|
134
198
|
);
|
|
135
199
|
|
|
136
200
|
if (SSH_DEPLOY_TYPES.has(deployType)) {
|
|
137
|
-
const host =
|
|
138
|
-
const user =
|
|
139
|
-
const keyPath =
|
|
140
|
-
const sshPort = Number(process.env.SSH_SSH_PORT ||
|
|
201
|
+
const host = settings.host || process.env.SSH_HOST;
|
|
202
|
+
const user = settings.user || process.env.SSH_USER;
|
|
203
|
+
const keyPath = settings.keyPath || process.env.SSH_KEY_PATH;
|
|
204
|
+
const sshPort = Number(process.env.SSH_SSH_PORT || settings.sshPort) || 22;
|
|
141
205
|
|
|
142
206
|
checks.push(
|
|
143
207
|
await runCheck('SSH key', async () => {
|
|
@@ -171,7 +235,7 @@ async function runDeploymentChecks(config, envName, envConfig) {
|
|
|
171
235
|
})
|
|
172
236
|
);
|
|
173
237
|
|
|
174
|
-
const deployPaths = resolveSshDeployPaths(config,
|
|
238
|
+
const deployPaths = resolveSshDeployPaths(config, settings);
|
|
175
239
|
const sshUser = String(user || process.env.SSH_USER || 'your-user');
|
|
176
240
|
|
|
177
241
|
for (const deployPath of deployPaths) {
|
|
@@ -634,11 +698,17 @@ export function registerDoctorCommand(program) {
|
|
|
634
698
|
program
|
|
635
699
|
.command('doctor')
|
|
636
700
|
.description('Run pre-flight checks before deploying')
|
|
637
|
-
.
|
|
701
|
+
.option(
|
|
702
|
+
'--all',
|
|
703
|
+
'Also run method checks for disabled environments (failures are informational only)'
|
|
704
|
+
)
|
|
705
|
+
.action(async (opts) => {
|
|
638
706
|
loadEnv();
|
|
639
707
|
const cwd = process.cwd();
|
|
640
708
|
/** @type {CheckResult[]} */
|
|
641
709
|
const results = [];
|
|
710
|
+
/** @type {Set<string>} names of checks that must not fail the summary */
|
|
711
|
+
const informationalCheckNames = new Set();
|
|
642
712
|
|
|
643
713
|
results.push(
|
|
644
714
|
await runCheck('Git', async () => {
|
|
@@ -801,14 +871,49 @@ export function registerDoctorCommand(program) {
|
|
|
801
871
|
}
|
|
802
872
|
}
|
|
803
873
|
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
874
|
+
// Per-environment summary + method checks (enabled by default; --all includes disabled)
|
|
875
|
+
const envNames = Object.keys(config.environments || {});
|
|
876
|
+
if (envNames.length > 0) {
|
|
877
|
+
console.log('');
|
|
878
|
+
console.log(chalk.bold('Environments:'));
|
|
879
|
+
for (const envName of envNames) {
|
|
880
|
+
const env = config.environments[envName];
|
|
881
|
+
const method = getEnvMethod(env) || '?';
|
|
882
|
+
const enabled = isEnvEnabled(env);
|
|
883
|
+
const status = enabled ? 'enabled' : 'disabled';
|
|
884
|
+
let last = 'never';
|
|
885
|
+
try {
|
|
886
|
+
const { entries } = await loadEnvArtifactHistory(
|
|
887
|
+
config.storage || [],
|
|
888
|
+
config.project,
|
|
889
|
+
envName,
|
|
890
|
+
{ defaultEnvironment: resolveDefaultEnvironmentName(config) }
|
|
891
|
+
);
|
|
892
|
+
if (entries[0]?.buildId) {
|
|
893
|
+
last = entries[0].buildId;
|
|
894
|
+
}
|
|
895
|
+
} catch {
|
|
896
|
+
// doctor never crashes
|
|
897
|
+
}
|
|
898
|
+
const icon = enabled ? chalk.green('✓') : chalk.gray('–');
|
|
899
|
+
const hint = enabled
|
|
900
|
+
? `last deploy: ${last}`
|
|
901
|
+
: `run \`deployhub env enable ${envName}\``;
|
|
902
|
+
console.log(
|
|
903
|
+
` ${icon} ${envName.padEnd(14)} (${method})`.padEnd(36) +
|
|
904
|
+
`${status.padEnd(10)} — ${hint}`
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
console.log('');
|
|
810
908
|
}
|
|
811
909
|
|
|
910
|
+
const { results: envResults, informationalCheckNames: envInfo } =
|
|
911
|
+
await collectEnvDeploymentChecks(config, envNames, {
|
|
912
|
+
includeDisabled: Boolean(opts.all),
|
|
913
|
+
});
|
|
914
|
+
results.push(...envResults);
|
|
915
|
+
for (const name of envInfo) informationalCheckNames.add(name);
|
|
916
|
+
|
|
812
917
|
results.push(
|
|
813
918
|
await runCheck('Health endpoint', async () => {
|
|
814
919
|
const url = config.healthCheck?.url;
|
|
@@ -851,10 +956,13 @@ export function registerDoctorCommand(program) {
|
|
|
851
956
|
const keys = PROVIDER_ENV_MAP[provider] || [];
|
|
852
957
|
required.push(...keys);
|
|
853
958
|
}
|
|
854
|
-
for (const envName of config.
|
|
959
|
+
for (const envName of Object.keys(config.environments || {})) {
|
|
855
960
|
const env = config.environments[envName];
|
|
856
|
-
if (!env
|
|
857
|
-
|
|
961
|
+
if (!isEnvEnabled(env)) continue;
|
|
962
|
+
const method = getEnvMethod(env);
|
|
963
|
+
if (!method) continue;
|
|
964
|
+
// Local/.env uses unprefixed names; prefixed CI names are checked separately below.
|
|
965
|
+
required.push(...getDeploymentSecretKeys(method, config));
|
|
858
966
|
}
|
|
859
967
|
|
|
860
968
|
const unique = [...new Set(required)];
|
|
@@ -871,10 +979,65 @@ export function registerDoctorCommand(program) {
|
|
|
871
979
|
message: `Missing: ${missing.join(', ')}`,
|
|
872
980
|
};
|
|
873
981
|
}
|
|
874
|
-
return { name: 'Secrets', pass: true, message: 'All required
|
|
982
|
+
return { name: 'Secrets', pass: true, message: 'All required secrets present' };
|
|
875
983
|
})
|
|
876
984
|
);
|
|
877
985
|
|
|
986
|
+
// Explicit CI-prefixed secret reminders for non-grandfathered environments
|
|
987
|
+
if (config) {
|
|
988
|
+
const inCi = process.env.GITHUB_ACTIONS === 'true' || process.env.CI === 'true';
|
|
989
|
+
for (const envName of Object.keys(config.environments || {})) {
|
|
990
|
+
const env = config.environments[envName];
|
|
991
|
+
if (!isEnvEnabled(env)) continue;
|
|
992
|
+
if (!envUsesPrefixedSecrets(envName, config)) continue;
|
|
993
|
+
const method = getEnvMethod(env);
|
|
994
|
+
if (!method) continue;
|
|
995
|
+
|
|
996
|
+
const prefixedKeys = getDeploymentSecretKeysForEnv(
|
|
997
|
+
envName,
|
|
998
|
+
method,
|
|
999
|
+
config,
|
|
1000
|
+
config.environments
|
|
1001
|
+
);
|
|
1002
|
+
const baseKeys = getDeploymentSecretKeys(method, config);
|
|
1003
|
+
|
|
1004
|
+
for (let i = 0; i < baseKeys.length; i++) {
|
|
1005
|
+
const base = baseKeys[i];
|
|
1006
|
+
const prefixed = prefixedKeys[i] || base;
|
|
1007
|
+
if (prefixed === base) continue;
|
|
1008
|
+
|
|
1009
|
+
const checkName = `CI secret ${prefixed}`;
|
|
1010
|
+
results.push(
|
|
1011
|
+
await runCheck(checkName, async () => {
|
|
1012
|
+
if (process.env[prefixed]) {
|
|
1013
|
+
return {
|
|
1014
|
+
name: checkName,
|
|
1015
|
+
pass: true,
|
|
1016
|
+
message: `Present for environment "${envName}"`,
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
const localFallback =
|
|
1020
|
+
base === 'SSH_KEY'
|
|
1021
|
+
? process.env.SSH_KEY || process.env.SSH_KEY_PATH
|
|
1022
|
+
: process.env[base];
|
|
1023
|
+
if (!inCi && localFallback) {
|
|
1024
|
+
return {
|
|
1025
|
+
name: checkName,
|
|
1026
|
+
pass: false,
|
|
1027
|
+
message: `Add GitHub Actions secret "${prefixed}" for env "${envName}" (local has ${base}, but CI needs the prefixed name)`,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
return {
|
|
1031
|
+
name: checkName,
|
|
1032
|
+
pass: false,
|
|
1033
|
+
message: `Missing — add GitHub Actions secret "${prefixed}" for environment "${envName}"`,
|
|
1034
|
+
};
|
|
1035
|
+
})
|
|
1036
|
+
);
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
878
1041
|
results.push(
|
|
879
1042
|
await runCheck('GitHub Actions', async () => {
|
|
880
1043
|
const workflowPath = path.join(cwd, '.github', 'workflows', 'deployhub.yml');
|
|
@@ -910,6 +1073,13 @@ export function registerDoctorCommand(program) {
|
|
|
910
1073
|
);
|
|
911
1074
|
}
|
|
912
1075
|
|
|
1076
|
+
if (config) {
|
|
1077
|
+
const driftChecks = await getWorkflowDriftDoctorChecks(cwd, config);
|
|
1078
|
+
for (const check of driftChecks) {
|
|
1079
|
+
results.push(await runCheck(check.name, async () => check));
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
913
1083
|
results.push(
|
|
914
1084
|
await runCheck('Storage write', async () => {
|
|
915
1085
|
const provider = createLocalProvider();
|
|
@@ -928,14 +1098,21 @@ export function registerDoctorCommand(program) {
|
|
|
928
1098
|
);
|
|
929
1099
|
|
|
930
1100
|
console.log('');
|
|
931
|
-
const pad = (name) => name.padEnd(
|
|
1101
|
+
const pad = (name) => name.padEnd(28);
|
|
932
1102
|
for (const r of results) {
|
|
933
|
-
const
|
|
934
|
-
|
|
1103
|
+
const info = informationalCheckNames.has(r.name);
|
|
1104
|
+
const icon = r.pass
|
|
1105
|
+
? chalk.green('✓')
|
|
1106
|
+
: info
|
|
1107
|
+
? chalk.yellow('!')
|
|
1108
|
+
: chalk.red('✗');
|
|
1109
|
+
const suffix = info && !r.pass ? chalk.gray(' (informational — disabled env)') : '';
|
|
1110
|
+
console.log(` Checking ${pad(r.name)}... ${icon} ${r.message}${suffix}`);
|
|
935
1111
|
}
|
|
936
1112
|
|
|
937
|
-
const
|
|
938
|
-
const
|
|
1113
|
+
const blocking = results.filter((r) => !informationalCheckNames.has(r.name));
|
|
1114
|
+
const passed = blocking.filter((r) => r.pass).length;
|
|
1115
|
+
const total = blocking.length;
|
|
939
1116
|
console.log('');
|
|
940
1117
|
if (passed === total) {
|
|
941
1118
|
console.log(chalk.green.bold(` ✓ Ready to deploy (${passed}/${total} checks passed)`));
|
|
@@ -950,7 +1127,16 @@ export function registerDoctorCommand(program) {
|
|
|
950
1127
|
console.log('');
|
|
951
1128
|
printDoctorFooter();
|
|
952
1129
|
console.log('');
|
|
1130
|
+
|
|
1131
|
+
if (doctorHasBlockingFailures(results, informationalCheckNames)) {
|
|
1132
|
+
process.exit(1);
|
|
1133
|
+
}
|
|
953
1134
|
});
|
|
954
1135
|
}
|
|
955
1136
|
|
|
956
|
-
export default {
|
|
1137
|
+
export default {
|
|
1138
|
+
registerDoctorCommand,
|
|
1139
|
+
runDeploymentChecks,
|
|
1140
|
+
doctorHasBlockingFailures,
|
|
1141
|
+
collectEnvDeploymentChecks,
|
|
1142
|
+
};
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import inquirer from 'inquirer';
|
|
3
|
+
import {
|
|
4
|
+
loadConfig,
|
|
5
|
+
loadEnv,
|
|
6
|
+
saveConfig,
|
|
7
|
+
} from '../core/config.js';
|
|
8
|
+
import {
|
|
9
|
+
getEnvMethod,
|
|
10
|
+
getEnvTrigger,
|
|
11
|
+
isEnvEnabled,
|
|
12
|
+
resolveDefaultEnvironmentName,
|
|
13
|
+
validateEnvironmentName,
|
|
14
|
+
} from '../core/environments.js';
|
|
15
|
+
import { promptServerDeployment, buildServerEnvEntry } from '../deployment/init-prompts.js';
|
|
16
|
+
import { writeWorkflowFile } from '../utils/github-actions.js';
|
|
17
|
+
import { loadEnvArtifactHistory } from '../storage/index.js';
|
|
18
|
+
import { createLogger } from '../logger/index.js';
|
|
19
|
+
import {
|
|
20
|
+
envUsesPrefixedSecrets,
|
|
21
|
+
getDeploymentWorkflowSecretKeysForEnv,
|
|
22
|
+
} from '../deployment/deployment-env.js';
|
|
23
|
+
import {
|
|
24
|
+
envHistoryRemoteKey,
|
|
25
|
+
envLatestArtifactRemoteKey,
|
|
26
|
+
} from '../utils/build-id.js';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Best-effort last buildId for an env from per-env deploy history.
|
|
30
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
31
|
+
* @param {string} envName
|
|
32
|
+
* @returns {Promise<string>}
|
|
33
|
+
*/
|
|
34
|
+
async function lastDeployedBuildId(config, envName) {
|
|
35
|
+
try {
|
|
36
|
+
const providers = config.storage || [];
|
|
37
|
+
if (providers.length === 0) return '—';
|
|
38
|
+
const { entries } = await loadEnvArtifactHistory(providers, config.project, envName, {
|
|
39
|
+
defaultEnvironment: resolveDefaultEnvironmentName(config),
|
|
40
|
+
});
|
|
41
|
+
if (entries[0]?.buildId) return entries[0].buildId;
|
|
42
|
+
} catch {
|
|
43
|
+
// list never crashes
|
|
44
|
+
}
|
|
45
|
+
return '—';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {import('commander').Command} program
|
|
50
|
+
*/
|
|
51
|
+
export function registerEnvCommand(program) {
|
|
52
|
+
const env = program.command('env').description('Manage deployment environments');
|
|
53
|
+
|
|
54
|
+
env
|
|
55
|
+
.command('list')
|
|
56
|
+
.description('List configured environments')
|
|
57
|
+
.action(async () => {
|
|
58
|
+
loadEnv();
|
|
59
|
+
const config = await loadConfig();
|
|
60
|
+
const names = Object.keys(config.environments || {});
|
|
61
|
+
if (names.length === 0) {
|
|
62
|
+
console.log(chalk.yellow('No environments configured. Run: deployhub env add <name>'));
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const def = resolveDefaultEnvironmentName(config);
|
|
67
|
+
console.log('');
|
|
68
|
+
console.log(
|
|
69
|
+
chalk.bold(
|
|
70
|
+
`${'Name'.padEnd(18)} ${'Method'.padEnd(12)} ${'Status'.padEnd(10)} ${'Trigger'.padEnd(8)} Last deploy`
|
|
71
|
+
)
|
|
72
|
+
);
|
|
73
|
+
console.log(chalk.gray('─'.repeat(72)));
|
|
74
|
+
|
|
75
|
+
for (const name of names) {
|
|
76
|
+
const entry = config.environments[name];
|
|
77
|
+
const method = getEnvMethod(entry) || '?';
|
|
78
|
+
const status = isEnvEnabled(entry) ? 'enabled' : 'disabled';
|
|
79
|
+
const trigger = getEnvTrigger(entry);
|
|
80
|
+
const last = await lastDeployedBuildId(config, name);
|
|
81
|
+
const label = name === def ? `${name}*` : name;
|
|
82
|
+
console.log(
|
|
83
|
+
`${label.padEnd(18)} ${method.padEnd(12)} ${status.padEnd(10)} ${trigger.padEnd(8)} ${last}`
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
console.log('');
|
|
87
|
+
console.log(chalk.gray('* defaultEnvironment'));
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
env
|
|
91
|
+
.command('add <name>')
|
|
92
|
+
.description('Add a named deployment environment')
|
|
93
|
+
.option(
|
|
94
|
+
'--method <type>',
|
|
95
|
+
'Deployment method (ssh|docker|ec2|azure-vm|gcp-vm|kubernetes); skips the type prompt'
|
|
96
|
+
)
|
|
97
|
+
.option(
|
|
98
|
+
'--yes',
|
|
99
|
+
'Non-interactive: use defaults for the chosen --method (required with --yes)'
|
|
100
|
+
)
|
|
101
|
+
.action(async (rawName, opts) => {
|
|
102
|
+
loadEnv();
|
|
103
|
+
const cwd = process.cwd();
|
|
104
|
+
const config = await loadConfig(cwd);
|
|
105
|
+
const log = createLogger('env');
|
|
106
|
+
|
|
107
|
+
const nameCheck = validateEnvironmentName(rawName, Object.keys(config.environments || {}));
|
|
108
|
+
if (!nameCheck.ok) {
|
|
109
|
+
console.error(chalk.red(nameCheck.error));
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
const name = nameCheck.name;
|
|
113
|
+
|
|
114
|
+
if (opts.yes && !opts.method) {
|
|
115
|
+
console.error(
|
|
116
|
+
chalk.red('Non-interactive --yes requires --method <ssh|docker|ec2|azure-vm|gcp-vm|kubernetes>.')
|
|
117
|
+
);
|
|
118
|
+
process.exit(1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const projectType = config.projectType || 'frontend';
|
|
122
|
+
const backendConfig = config.backend || null;
|
|
123
|
+
const singleConfig =
|
|
124
|
+
projectType === 'both'
|
|
125
|
+
? null
|
|
126
|
+
: {
|
|
127
|
+
framework: config.framework,
|
|
128
|
+
port: config.port,
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const existingNames = Object.keys(config.environments || {});
|
|
132
|
+
// Permanently grandfather the original environment's unprefixed secrets.
|
|
133
|
+
if (!config.unprefixedSecretEnvironment && existingNames.length === 1) {
|
|
134
|
+
config.unprefixedSecretEnvironment = existingNames[0];
|
|
135
|
+
} else if (!config.unprefixedSecretEnvironment && existingNames.length === 0) {
|
|
136
|
+
// First env ever — this new one stays unprefixed.
|
|
137
|
+
config.unprefixedSecretEnvironment = name;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
let deployAnswers;
|
|
141
|
+
try {
|
|
142
|
+
deployAnswers = await promptServerDeployment(
|
|
143
|
+
config.project,
|
|
144
|
+
projectType,
|
|
145
|
+
backendConfig,
|
|
146
|
+
{
|
|
147
|
+
envName: name,
|
|
148
|
+
existingEnvNames: Object.keys(config.environments || {}),
|
|
149
|
+
deployType: opts.method,
|
|
150
|
+
nonInteractive: Boolean(opts.yes),
|
|
151
|
+
}
|
|
152
|
+
);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
config.environments[name] = buildServerEnvEntry(
|
|
159
|
+
deployAnswers,
|
|
160
|
+
projectType,
|
|
161
|
+
config.project,
|
|
162
|
+
backendConfig,
|
|
163
|
+
singleConfig
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (!config.defaultEnvironment) {
|
|
167
|
+
config.defaultEnvironment = name;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
config.pipeline = config.pipeline || {};
|
|
171
|
+
config.pipeline.deploy = true;
|
|
172
|
+
|
|
173
|
+
await saveConfig(config, cwd);
|
|
174
|
+
|
|
175
|
+
const enabledNames = Object.keys(config.environments).filter((n) =>
|
|
176
|
+
isEnvEnabled(config.environments[n])
|
|
177
|
+
);
|
|
178
|
+
await writeWorkflowFile(
|
|
179
|
+
config.storage || [],
|
|
180
|
+
enabledNames,
|
|
181
|
+
config.environments,
|
|
182
|
+
cwd,
|
|
183
|
+
config.cli?.source,
|
|
184
|
+
config
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
log.success(`Added environment "${name}" (${deployAnswers.deployType})`);
|
|
188
|
+
console.log(chalk.gray(` defaultEnvironment: ${config.defaultEnvironment}`));
|
|
189
|
+
console.log(chalk.gray(' Workflows regenerated — commit .github/workflows if using CI.'));
|
|
190
|
+
|
|
191
|
+
const envCount = Object.keys(config.environments).length;
|
|
192
|
+
if (envCount >= 2 && envUsesPrefixedSecrets(name, config)) {
|
|
193
|
+
const original = config.unprefixedSecretEnvironment;
|
|
194
|
+
const method = getEnvMethod(config.environments[name]);
|
|
195
|
+
const newSecrets = method
|
|
196
|
+
? getDeploymentWorkflowSecretKeysForEnv(name, method, config, config.environments)
|
|
197
|
+
: [];
|
|
198
|
+
const requiredNew = newSecrets.filter((k) => !k.includes('OPTIONAL'));
|
|
199
|
+
console.log('');
|
|
200
|
+
console.log(chalk.yellow(`⚠ You now have ${envCount} environments configured.`));
|
|
201
|
+
if (original) {
|
|
202
|
+
console.log(
|
|
203
|
+
chalk.yellow(
|
|
204
|
+
` "${original}" keeps using its existing unprefixed secrets (e.g. SSH_HOST) — no action needed.`
|
|
205
|
+
)
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
console.log(
|
|
209
|
+
chalk.yellow(
|
|
210
|
+
` "${name}" requires new secrets in CI: ${requiredNew.slice(0, 8).join(', ')}${
|
|
211
|
+
requiredNew.length > 8 ? ', …' : ''
|
|
212
|
+
}`
|
|
213
|
+
)
|
|
214
|
+
);
|
|
215
|
+
console.log(
|
|
216
|
+
chalk.yellow(
|
|
217
|
+
' Add these to your GitHub repo secrets before running a deploy/rollback for this environment in CI.'
|
|
218
|
+
)
|
|
219
|
+
);
|
|
220
|
+
console.log(chalk.yellow(' Run `deployhub sync-workflows` after adding them (already regenerated).'));
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
env
|
|
225
|
+
.command('enable <name>')
|
|
226
|
+
.description('Enable an environment for deploy / --env all')
|
|
227
|
+
.action(async (name) => {
|
|
228
|
+
loadEnv();
|
|
229
|
+
const cwd = process.cwd();
|
|
230
|
+
const config = await loadConfig(cwd);
|
|
231
|
+
if (!config.environments[name]) {
|
|
232
|
+
console.error(chalk.red(`Environment "${name}" not found.`));
|
|
233
|
+
process.exit(1);
|
|
234
|
+
}
|
|
235
|
+
config.environments[name].enabled = true;
|
|
236
|
+
await saveConfig(config, cwd);
|
|
237
|
+
console.log(chalk.green(`✓ Enabled environment "${name}"`));
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
env
|
|
241
|
+
.command('disable <name>')
|
|
242
|
+
.description('Disable an environment (deploy to it will fail until re-enabled)')
|
|
243
|
+
.action(async (name) => {
|
|
244
|
+
loadEnv();
|
|
245
|
+
const cwd = process.cwd();
|
|
246
|
+
const config = await loadConfig(cwd);
|
|
247
|
+
if (!config.environments[name]) {
|
|
248
|
+
console.error(chalk.red(`Environment "${name}" not found.`));
|
|
249
|
+
process.exit(1);
|
|
250
|
+
}
|
|
251
|
+
config.environments[name].enabled = false;
|
|
252
|
+
await saveConfig(config, cwd);
|
|
253
|
+
console.log(chalk.yellow(`Disabled environment "${name}"`));
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
env
|
|
257
|
+
.command('remove <name>')
|
|
258
|
+
.description('Remove an environment and its rollback history reference')
|
|
259
|
+
.action(async (name) => {
|
|
260
|
+
loadEnv();
|
|
261
|
+
const cwd = process.cwd();
|
|
262
|
+
const config = await loadConfig(cwd);
|
|
263
|
+
if (!config.environments[name]) {
|
|
264
|
+
console.error(chalk.red(`Environment "${name}" not found.`));
|
|
265
|
+
process.exit(1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const { confirm } = await inquirer.prompt([
|
|
269
|
+
{
|
|
270
|
+
type: 'confirm',
|
|
271
|
+
name: 'confirm',
|
|
272
|
+
message: `Remove environment "${name}"? Its per-environment rollback history will no longer be tracked by DeployHub.`,
|
|
273
|
+
default: false,
|
|
274
|
+
},
|
|
275
|
+
]);
|
|
276
|
+
if (!confirm) {
|
|
277
|
+
console.log(chalk.gray('Cancelled.'));
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (config.defaultEnvironment === name) {
|
|
282
|
+
const remaining = Object.keys(config.environments).filter((n) => n !== name);
|
|
283
|
+
if (remaining.length > 0) {
|
|
284
|
+
console.error(
|
|
285
|
+
chalk.red(
|
|
286
|
+
`Cannot remove "${name}" while it is defaultEnvironment.\n` +
|
|
287
|
+
` Set a new default first (edit defaultEnvironment in deployhub.config.json), ` +
|
|
288
|
+
`then run deployhub env remove ${name} again.`
|
|
289
|
+
)
|
|
290
|
+
);
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const historyKey = envHistoryRemoteKey(config.project, name);
|
|
296
|
+
const latestKey = envLatestArtifactRemoteKey(config.project, name);
|
|
297
|
+
|
|
298
|
+
delete config.environments[name];
|
|
299
|
+
if (config.defaultEnvironment === name) {
|
|
300
|
+
config.defaultEnvironment = undefined;
|
|
301
|
+
}
|
|
302
|
+
await saveConfig(config, cwd);
|
|
303
|
+
|
|
304
|
+
const enabledNames = Object.keys(config.environments).filter((n) =>
|
|
305
|
+
isEnvEnabled(config.environments[n])
|
|
306
|
+
);
|
|
307
|
+
await writeWorkflowFile(
|
|
308
|
+
config.storage || [],
|
|
309
|
+
enabledNames,
|
|
310
|
+
config.environments,
|
|
311
|
+
cwd,
|
|
312
|
+
config.cli?.source,
|
|
313
|
+
config
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
console.log(chalk.green(`✓ Removed environment "${name}"`));
|
|
317
|
+
console.log(
|
|
318
|
+
chalk.yellow(
|
|
319
|
+
` Storage not deleted (orphaned): ${historyKey}, ${latestKey}\n` +
|
|
320
|
+
' Remove these manually from your storage provider if you no longer need rollback data.'
|
|
321
|
+
)
|
|
322
|
+
);
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export default { registerEnvCommand };
|