@akash-chowdhury-24/deployhub 2.0.15 → 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 +47 -16
- package/package.json +1 -1
- package/src/artifact/engine.js +11 -4
- package/src/cli/index.js +2 -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/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/github-actions.js +344 -31
- package/src/utils/health-check.js +158 -0
- package/src/utils/kubernetes-manifests.js +3 -1
- package/src/utils/nginx.js +66 -15
- package/src/utils/rollback/engine.js +147 -40
- package/src/utils/scaffold.js +2 -1
package/README.md
CHANGED
|
@@ -229,9 +229,22 @@ Or push to `main` / `master` — the generated workflow runs the same command.
|
|
|
229
229
|
deployhub artifact list # local artifacts
|
|
230
230
|
deployhub artifact list --remote # include storage history.json
|
|
231
231
|
deployhub artifact restore <buildId> # download a past build
|
|
232
|
-
|
|
233
|
-
deployhub
|
|
234
|
-
deployhub
|
|
232
|
+
|
|
233
|
+
deployhub deploy # deploy latest artifact to defaultEnvironment
|
|
234
|
+
deployhub deploy --env staging # deploy one named environment
|
|
235
|
+
deployhub deploy --env all # every enabled environment (skips disabled)
|
|
236
|
+
|
|
237
|
+
deployhub rollback # previous build for defaultEnvironment
|
|
238
|
+
deployhub rollback --env production # roll back one env using its own history
|
|
239
|
+
deployhub rollback 1.0.6-a1b2c3d --env staging
|
|
240
|
+
deployhub rollback --env all # continue-on-error across enabled envs
|
|
241
|
+
|
|
242
|
+
deployhub env list # list environments (method, status, last deploy)
|
|
243
|
+
deployhub env add staging # add an environment (interactive method prompts)
|
|
244
|
+
deployhub env enable staging # include in deploy / --env all
|
|
245
|
+
deployhub env disable staging # exclude without deleting config
|
|
246
|
+
deployhub env remove staging # remove env (re-point defaultEnvironment first if needed)
|
|
247
|
+
|
|
235
248
|
deployhub sync-workflows # regenerate deploy + rollback GitHub Actions YAML
|
|
236
249
|
deployhub sync-k8s-ports # fix containerPort/targetPort in existing k8s manifests
|
|
237
250
|
deployhub logs # last deployment logs
|
|
@@ -239,7 +252,7 @@ deployhub logs # last deployment logs
|
|
|
239
252
|
|
|
240
253
|
### Rollback behavior
|
|
241
254
|
|
|
242
|
-
`deployhub rollback` restores a previous artifact from
|
|
255
|
+
`deployhub rollback` restores a previous artifact from **that environment's** `envs/{env}/history.json` (legacy project `history.json` is treated as the default env's history) and redeploys it:
|
|
243
256
|
|
|
244
257
|
| Argument | Behavior |
|
|
245
258
|
|----------|----------|
|
|
@@ -293,12 +306,14 @@ Artifacts appear under `artifact/{projectName}/{date}/v{buildId}/` locally.
|
|
|
293
306
|
**Remote storage** (S3 and other providers) uses a different layout:
|
|
294
307
|
|
|
295
308
|
```text
|
|
296
|
-
{project}/builds/{buildId}/artifact.zip
|
|
297
|
-
{project}/history.json
|
|
298
|
-
{project}/latest/artifact.zip
|
|
309
|
+
{project}/builds/{buildId}/artifact.zip # immutable per CI/build (shared across envs)
|
|
310
|
+
{project}/history.json # build catalog for artifact list --remote
|
|
311
|
+
{project}/latest/artifact.zip # last uploaded build (NOT a backup)
|
|
312
|
+
{project}/envs/{env}/history.json # per-environment deploy history (rollback)
|
|
313
|
+
{project}/envs/{env}/latest/artifact.zip # last deploy to that env (NOT a backup)
|
|
299
314
|
```
|
|
300
315
|
|
|
301
|
-
`buildId` is unique per pipeline run (e.g. `1.0.6-a1b2c3d`) even if `package.json` semver is unchanged. Legacy keys `{project}/v{semver}/artifact.zip` are no longer written; they remain readable for older uploads only.
|
|
316
|
+
`buildId` is unique per pipeline run (e.g. `1.0.6-a1b2c3d`) even if `package.json` semver is unchanged. Legacy keys `{project}/v{semver}/artifact.zip` are no longer written; they remain readable for older uploads only. Build once, then promote the same `buildId` to any environment with `deployhub deploy --env`.
|
|
302
317
|
|
|
303
318
|
### Same steps for other languages
|
|
304
319
|
|
|
@@ -987,23 +1002,35 @@ Run `deployhub doctor` after any config change.
|
|
|
987
1002
|
|
|
988
1003
|
| Command | Description |
|
|
989
1004
|
|---------|-------------|
|
|
990
|
-
| `deployhub init` | Interactive project setup |
|
|
991
|
-
| `deployhub build` | Full pipeline: detect → install → test → build → artifact → storage → deploy |
|
|
1005
|
+
| `deployhub init` | Interactive project setup (one or multiple environments) |
|
|
1006
|
+
| `deployhub build` | Full pipeline: detect → install → test → build → artifact → storage → deploy (default / push-triggered envs) |
|
|
992
1007
|
| `deployhub artifact create` | Create artifact from current build |
|
|
993
1008
|
| `deployhub artifact list [--remote]` | List local artifacts; `--remote` merges storage `history.json` |
|
|
994
1009
|
| `deployhub artifact restore <buildId\|semver>` | Download and extract an artifact |
|
|
995
1010
|
| `deployhub storage add <provider>` | Add storage provider credentials |
|
|
996
1011
|
| `deployhub storage list` | List storage providers and connection status |
|
|
997
|
-
| `deployhub deploy` | Deploy latest artifact |
|
|
998
|
-
| `deployhub
|
|
1012
|
+
| `deployhub deploy` | Deploy latest artifact to `defaultEnvironment` |
|
|
1013
|
+
| `deployhub deploy --env staging` | Deploy to one named environment |
|
|
1014
|
+
| `deployhub deploy --env all` | Deploy to every enabled environment (skips disabled) |
|
|
1015
|
+
| `deployhub rollback` | Roll back `defaultEnvironment` to its previous build |
|
|
1016
|
+
| `deployhub rollback --env production` | Roll back one env from `envs/{env}/history.json` |
|
|
1017
|
+
| `deployhub rollback <buildId> --env staging` | Restore an exact `buildId` to that environment |
|
|
1018
|
+
| `deployhub rollback --env all` | Roll back each enabled env (continue-on-error + summary) |
|
|
1019
|
+
| `deployhub env list` | List environments (method, enabled, trigger, last deploy) |
|
|
1020
|
+
| `deployhub env add <name>` | Add an environment (interactive method prompts; regenerates workflows) |
|
|
1021
|
+
| `deployhub env enable <name>` | Enable an environment for deploy / `--env all` |
|
|
1022
|
+
| `deployhub env disable <name>` | Disable without deleting config |
|
|
1023
|
+
| `deployhub env remove <name>` | Remove an environment (re-point `defaultEnvironment` first if needed) |
|
|
999
1024
|
| `deployhub sync-workflows` | Regenerate `.github/workflows/deployhub.yml` and `deployhub-rollback.yml` from config |
|
|
1000
1025
|
| `deployhub sync-k8s-ports` | Update only `containerPort` / `targetPort` in `k8s/deployment.yaml` and `k8s/service.yaml` from Dockerfile `EXPOSE` (or config/fallback). Does **not** change replicas, resources, env, or probes. Heavily customized / multi-container manifests may need a manual review |
|
|
1001
1026
|
| `deployhub logs` | Show logs from last deployment |
|
|
1002
|
-
| `deployhub doctor` | Pre-flight checks
|
|
1027
|
+
| `deployhub doctor [--all]` | Pre-flight checks per environment (`--all` includes disabled envs as informational) |
|
|
1003
1028
|
| `deployhub verify` | Health check on configured endpoint |
|
|
1004
1029
|
| `deployhub clean` | Remove old local artifacts |
|
|
1005
1030
|
| `deployhub update` | Check for CLI updates |
|
|
1006
1031
|
|
|
1032
|
+
**Tests:** `npm test` — currently **270 passing** across the Jest suites.
|
|
1033
|
+
|
|
1007
1034
|
## GitHub Secrets
|
|
1008
1035
|
|
|
1009
1036
|
Add these secrets in your repository (Settings → Secrets and variables → Actions). Only add secrets for providers you selected during `init`. At the end of `deployhub init`, DeployHub prints the exact list for your project.
|
|
@@ -1072,6 +1099,8 @@ The doctor command runs independent checks and always completes without crashing
|
|
|
1072
1099
|
|
|
1073
1100
|
The **Rollback workflow** check is informational and non-blocking (`✓` even when the file is missing). It suggests `deployhub sync-workflows` so already-initialized projects can pick up `deployhub-rollback.yml` without a full re-init. When the file exists, the message confirms the path instead.
|
|
1074
1101
|
|
|
1102
|
+
If checked-in workflow files exist but are **out of date** with `deployhub.config.json` (e.g. you ran `env add` or a silent migration and forgot `sync-workflows`), doctor also reports an informational **Workflow sync** nudge naming the missing environment or secret and suggesting `deployhub sync-workflows`. That check never fails the doctor exit code.
|
|
1103
|
+
|
|
1075
1104
|
If checks fail:
|
|
1076
1105
|
|
|
1077
1106
|
```
|
|
@@ -1104,9 +1133,11 @@ artifact/
|
|
|
1104
1133
|
**Remote keys** (all storage providers):
|
|
1105
1134
|
|
|
1106
1135
|
```
|
|
1107
|
-
{project}/builds/{buildId}/artifact.zip
|
|
1108
|
-
{project}/history.json
|
|
1109
|
-
{project}/latest/artifact.zip
|
|
1136
|
+
{project}/builds/{buildId}/artifact.zip # immutable per CI/build (shared across envs)
|
|
1137
|
+
{project}/history.json # build catalog for artifact list --remote
|
|
1138
|
+
{project}/latest/artifact.zip # overwritten every build — convenience pointer only
|
|
1139
|
+
{project}/envs/{env}/history.json # per-environment deploy history (rollback)
|
|
1140
|
+
{project}/envs/{env}/latest/artifact.zip # last deploy to that env (NOT a backup)
|
|
1110
1141
|
```
|
|
1111
1142
|
|
|
1112
1143
|
Legacy (read-only fallback, no longer written): `{project}/v{semver}/artifact.zip`
|
package/package.json
CHANGED
package/src/artifact/engine.js
CHANGED
|
@@ -13,6 +13,12 @@ import {
|
|
|
13
13
|
copyDeployAssetsToArtifactDir,
|
|
14
14
|
} from '../utils/scaffold.js';
|
|
15
15
|
import { getGeneratedByMetadata, getArtifactReadmeFooter } from '../utils/author.js';
|
|
16
|
+
import {
|
|
17
|
+
getEnabledEnvironmentNames,
|
|
18
|
+
getEnvMethod,
|
|
19
|
+
getEnvSettings,
|
|
20
|
+
resolveDefaultEnvironmentName,
|
|
21
|
+
} from '../core/config.js';
|
|
16
22
|
|
|
17
23
|
/**
|
|
18
24
|
* @param {string} cwd
|
|
@@ -151,13 +157,14 @@ async function stageFrontendArtifact(cwd, stagingDir, config) {
|
|
|
151
157
|
|
|
152
158
|
await copyKubernetesManifestsIfPresent(cwd, stagingDir);
|
|
153
159
|
|
|
154
|
-
const
|
|
155
|
-
|
|
160
|
+
const deployTargets = getEnabledEnvironmentNames(config);
|
|
161
|
+
const hasSshDeploy = deployTargets.some(
|
|
162
|
+
(envName) => getEnvMethod(config.environments[envName]) === 'ssh'
|
|
156
163
|
);
|
|
157
164
|
|
|
158
165
|
if (hasSshDeploy && !fs.existsSync(path.join(stagingDir, 'nginx.conf'))) {
|
|
159
|
-
const envName = config
|
|
160
|
-
const env = envName ? config.environments[envName] : null;
|
|
166
|
+
const envName = resolveDefaultEnvironmentName(config) || deployTargets[0];
|
|
167
|
+
const env = envName ? getEnvSettings(config.environments[envName]) : null;
|
|
161
168
|
const deployPath =
|
|
162
169
|
env?.frontendDeployPath || env?.deployPath || env?.path || `/var/www/${config.project}`;
|
|
163
170
|
const nginxConf = generateNginxConfig(config.project, deployPath, outputName);
|
package/src/cli/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { registerCleanCommand } from '../commands/clean.js';
|
|
|
15
15
|
import { registerUpdateCommand } from '../commands/update.js';
|
|
16
16
|
import { registerSyncWorkflowsCommand } from '../commands/sync-workflows.js';
|
|
17
17
|
import { registerSyncK8sPortsCommand } from '../commands/sync-k8s-ports.js';
|
|
18
|
+
import { registerEnvCommand } from '../commands/env.js';
|
|
18
19
|
import { formatVersionOutput, printBanner, shouldShowBanner } from '../utils/author.js';
|
|
19
20
|
|
|
20
21
|
loadEnv();
|
|
@@ -45,5 +46,6 @@ registerCleanCommand(program);
|
|
|
45
46
|
registerUpdateCommand(program);
|
|
46
47
|
registerSyncWorkflowsCommand(program);
|
|
47
48
|
registerSyncK8sPortsCommand(program);
|
|
49
|
+
registerEnvCommand(program);
|
|
48
50
|
|
|
49
51
|
program.parse();
|
package/src/commands/deploy.js
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { loadConfig, loadEnv } from '../core/config.js';
|
|
3
|
+
import { resolveEnvTargets } from '../core/environments.js';
|
|
3
4
|
import { runPipeline } from '../core/pipeline.js';
|
|
4
5
|
import { listLocalArtifacts } from '../artifact/engine.js';
|
|
5
6
|
import { deployToAll } from '../deployment/index.js';
|
|
6
7
|
import { sendNotifications } from '../notifications/index.js';
|
|
7
|
-
import
|
|
8
|
+
import {
|
|
9
|
+
anyEnvHasResolvableHealthCheckUrl,
|
|
10
|
+
runHealthChecksForEnvs,
|
|
11
|
+
formatHealthCheckAllSummary,
|
|
12
|
+
} from '../utils/health-check.js';
|
|
13
|
+
import { createLogger } from '../logger/index.js';
|
|
8
14
|
|
|
9
15
|
/**
|
|
10
16
|
* @param {import('commander').Command} program
|
|
@@ -12,11 +18,34 @@ import axios from 'axios';
|
|
|
12
18
|
export function registerDeployCommand(program) {
|
|
13
19
|
program
|
|
14
20
|
.command('deploy')
|
|
15
|
-
.description('Deploy the latest artifact')
|
|
16
|
-
.
|
|
21
|
+
.description('Deploy the latest artifact (default environment, or --env)')
|
|
22
|
+
.option(
|
|
23
|
+
'--env <name>',
|
|
24
|
+
'Environment to deploy (name, or "all" for every enabled environment)'
|
|
25
|
+
)
|
|
26
|
+
.action(async (opts) => {
|
|
17
27
|
loadEnv();
|
|
18
28
|
const cwd = process.cwd();
|
|
19
29
|
const config = await loadConfig(cwd);
|
|
30
|
+
const log = createLogger('deploy');
|
|
31
|
+
|
|
32
|
+
let targets;
|
|
33
|
+
let skippedDisabled;
|
|
34
|
+
try {
|
|
35
|
+
({ targets, skippedDisabled } = resolveEnvTargets(config, opts.env));
|
|
36
|
+
} catch (err) {
|
|
37
|
+
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
for (const name of skippedDisabled) {
|
|
42
|
+
log.info(`Skipping disabled environment "${name}"`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (targets.length === 0) {
|
|
46
|
+
console.error(chalk.red('No enabled environments to deploy.'));
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
20
49
|
|
|
21
50
|
const artifacts = await listLocalArtifacts(cwd);
|
|
22
51
|
if (artifacts.length === 0) {
|
|
@@ -34,27 +63,43 @@ export function registerDeployCommand(program) {
|
|
|
34
63
|
async run(ctx) {
|
|
35
64
|
const deployed = await deployToAll(
|
|
36
65
|
ctx.config,
|
|
37
|
-
/** @type {string} */ (ctx.state.artifactDir)
|
|
66
|
+
/** @type {string} */ (ctx.state.artifactDir),
|
|
67
|
+
targets
|
|
38
68
|
);
|
|
39
69
|
ctx.state.deployedTargets = deployed;
|
|
40
70
|
},
|
|
41
71
|
},
|
|
42
72
|
{
|
|
43
73
|
name: 'verify',
|
|
44
|
-
enabled: (ctx) =>
|
|
74
|
+
enabled: (ctx) => {
|
|
75
|
+
const deployed = /** @type {string[]} */ (
|
|
76
|
+
ctx.state.deployedTargets || targets
|
|
77
|
+
);
|
|
78
|
+
return anyEnvHasResolvableHealthCheckUrl(ctx.config, deployed);
|
|
79
|
+
},
|
|
45
80
|
async run(ctx) {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (
|
|
55
|
-
|
|
81
|
+
const deployed = /** @type {string[]} */ (
|
|
82
|
+
ctx.state.deployedTargets || targets
|
|
83
|
+
);
|
|
84
|
+
const { results, failures } = await runHealthChecksForEnvs(
|
|
85
|
+
ctx.config,
|
|
86
|
+
deployed
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
if (deployed.length > 1 || failures.length > 0) {
|
|
90
|
+
console.log('');
|
|
91
|
+
console.log(formatHealthCheckAllSummary(results, failures));
|
|
92
|
+
} else if (results[0]) {
|
|
93
|
+
console.log(
|
|
94
|
+
chalk.green(
|
|
95
|
+
`Health check passed (${results[0].envName}): HTTP ${results[0].status} (${results[0].elapsed}ms)`
|
|
96
|
+
)
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (failures.length > 0) {
|
|
101
|
+
throw new Error(failures[0].error);
|
|
56
102
|
}
|
|
57
|
-
console.log(chalk.green(`Health check passed: ${response.status} (${elapsed}ms)`));
|
|
58
103
|
},
|
|
59
104
|
},
|
|
60
105
|
{
|
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
|
+
};
|