@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/src/commands/verify.js
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import axios from 'axios';
|
|
3
2
|
import { loadConfig, loadEnv } from '../core/config.js';
|
|
3
|
+
import { resolveEnvTargets } from '../core/environments.js';
|
|
4
|
+
import {
|
|
5
|
+
anyEnvHasResolvableHealthCheckUrl,
|
|
6
|
+
runHealthChecksForEnvs,
|
|
7
|
+
formatHealthCheckAllSummary,
|
|
8
|
+
} from '../utils/health-check.js';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Standalone verify — same per-env URL resolution and summary as the deploy pipeline stage.
|
|
12
|
+
*
|
|
13
|
+
* @param {import('../core/config.js').DeployHubConfig} config
|
|
14
|
+
* @param {string|undefined} envFlag — omitted = defaultEnvironment; `"all"` = every enabled
|
|
15
|
+
* @param {{ httpGet?: (url: string, timeoutMs: number) => Promise<{ status: number }> }} [options]
|
|
16
|
+
* @returns {Promise<{ ok: boolean, targets: string[], results: object[], failures: object[], summary: string, message: string }>}
|
|
17
|
+
*/
|
|
18
|
+
export async function runVerify(config, envFlag, options = {}) {
|
|
19
|
+
const { targets, skippedDisabled } = resolveEnvTargets(config, envFlag);
|
|
20
|
+
|
|
21
|
+
if (targets.length === 0) {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
targets,
|
|
25
|
+
results: [],
|
|
26
|
+
failures: [],
|
|
27
|
+
summary: '',
|
|
28
|
+
message: 'No enabled environments to verify.',
|
|
29
|
+
skippedDisabled,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (!anyEnvHasResolvableHealthCheckUrl(config, targets)) {
|
|
34
|
+
const label =
|
|
35
|
+
targets.length === 1
|
|
36
|
+
? `environment "${targets[0]}"`
|
|
37
|
+
: 'the selected environment(s)';
|
|
38
|
+
return {
|
|
39
|
+
ok: false,
|
|
40
|
+
targets,
|
|
41
|
+
results: [],
|
|
42
|
+
failures: [],
|
|
43
|
+
summary: '',
|
|
44
|
+
message: `No health check URL configured for ${label}. Set healthCheck.url or environments.<name>.config.healthCheckUrl.`,
|
|
45
|
+
skippedDisabled,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { results, failures } = await runHealthChecksForEnvs(config, targets, options);
|
|
50
|
+
const multi = targets.length > 1 || failures.length > 0;
|
|
51
|
+
const summary = multi ? formatHealthCheckAllSummary(results, failures) : '';
|
|
52
|
+
|
|
53
|
+
let message = '';
|
|
54
|
+
if (failures.length === 0 && results.length === 1 && !multi) {
|
|
55
|
+
const r = results[0];
|
|
56
|
+
message = `Health check passed (${r.envName}): HTTP ${r.status} (${r.elapsed}ms)`;
|
|
57
|
+
} else if (failures.length === 0 && results.length === 0) {
|
|
58
|
+
message = `No health check URL configured for the selected environment(s).`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
ok: failures.length === 0 && results.length > 0,
|
|
63
|
+
targets,
|
|
64
|
+
results,
|
|
65
|
+
failures,
|
|
66
|
+
summary,
|
|
67
|
+
message:
|
|
68
|
+
message ||
|
|
69
|
+
(failures.length > 0
|
|
70
|
+
? failures[0].error
|
|
71
|
+
: `All ${results.length} environment(s) passed health checks.`),
|
|
72
|
+
skippedDisabled,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
4
75
|
|
|
5
76
|
/**
|
|
6
77
|
* @param {import('commander').Command} program
|
|
@@ -8,48 +79,41 @@ import { loadConfig, loadEnv } from '../core/config.js';
|
|
|
8
79
|
export function registerVerifyCommand(program) {
|
|
9
80
|
program
|
|
10
81
|
.command('verify')
|
|
11
|
-
.description('Run health check on configured endpoint')
|
|
12
|
-
.
|
|
82
|
+
.description('Run health check on configured endpoint (default environment, or --env)')
|
|
83
|
+
.option(
|
|
84
|
+
'--env <name>',
|
|
85
|
+
'Environment to verify (name, or "all" for every enabled environment)'
|
|
86
|
+
)
|
|
87
|
+
.action(async (opts) => {
|
|
13
88
|
loadEnv();
|
|
14
89
|
const config = await loadConfig();
|
|
15
|
-
const url = config.healthCheck?.url;
|
|
16
90
|
|
|
17
|
-
|
|
18
|
-
|
|
91
|
+
let outcome;
|
|
92
|
+
try {
|
|
93
|
+
outcome = await runVerify(config, opts.env);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
|
|
19
96
|
process.exit(1);
|
|
20
97
|
}
|
|
21
98
|
|
|
22
|
-
const
|
|
23
|
-
|
|
99
|
+
for (const name of outcome.skippedDisabled || []) {
|
|
100
|
+
console.log(chalk.gray(`Skipping disabled environment "${name}"`));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (outcome.summary) {
|
|
104
|
+
console.log('');
|
|
105
|
+
console.log(outcome.summary);
|
|
106
|
+
} else if (outcome.ok && outcome.message) {
|
|
107
|
+
console.log(chalk.green(`✓ ${outcome.message}`));
|
|
108
|
+
}
|
|
24
109
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
validateStatus: () => true,
|
|
29
|
-
});
|
|
30
|
-
const elapsed = Date.now() - start;
|
|
31
|
-
const ok = response.status >= 200 && response.status < 400;
|
|
32
|
-
|
|
33
|
-
if (ok) {
|
|
34
|
-
console.log(
|
|
35
|
-
chalk.green(`✓ Health check passed: HTTP ${response.status} (${elapsed}ms)`)
|
|
36
|
-
);
|
|
37
|
-
} else {
|
|
38
|
-
console.log(
|
|
39
|
-
chalk.red(`✗ Health check failed: HTTP ${response.status} (${elapsed}ms)`)
|
|
40
|
-
);
|
|
41
|
-
process.exit(1);
|
|
110
|
+
if (!outcome.ok) {
|
|
111
|
+
if (!outcome.summary && outcome.message) {
|
|
112
|
+
console.error(chalk.red(`✗ ${outcome.message}`));
|
|
42
113
|
}
|
|
43
|
-
} catch (err) {
|
|
44
|
-
const elapsed = Date.now() - start;
|
|
45
|
-
console.error(
|
|
46
|
-
chalk.red(
|
|
47
|
-
`✗ Health check failed: ${err instanceof Error ? err.message : String(err)} (${elapsed}ms)`
|
|
48
|
-
)
|
|
49
|
-
);
|
|
50
114
|
process.exit(1);
|
|
51
115
|
}
|
|
52
116
|
});
|
|
53
117
|
}
|
|
54
118
|
|
|
55
|
-
export default { registerVerifyCommand };
|
|
119
|
+
export default { registerVerifyCommand, runVerify };
|
package/src/core/config.js
CHANGED
|
@@ -2,6 +2,20 @@ import fs from 'fs-extra';
|
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import dotenv from 'dotenv';
|
|
5
|
+
import { createLogger } from '../logger/index.js';
|
|
6
|
+
import { ensureLegacyHistoryCopiedToDefaultEnv } from '../storage/index.js';
|
|
7
|
+
import {
|
|
8
|
+
isNewEnvironmentShape,
|
|
9
|
+
getEnvMethod,
|
|
10
|
+
getEnvSettings,
|
|
11
|
+
isEnvEnabled,
|
|
12
|
+
getEnvTrigger,
|
|
13
|
+
resolveDefaultEnvironmentName,
|
|
14
|
+
getEnabledEnvironmentNames,
|
|
15
|
+
buildEnvironmentEntry,
|
|
16
|
+
resolveEnvTargets,
|
|
17
|
+
mergeMethodSettingsIntoEnv,
|
|
18
|
+
} from './environments.js';
|
|
5
19
|
|
|
6
20
|
const SideConfigSchema = z.object({
|
|
7
21
|
framework: z.string(),
|
|
@@ -12,36 +26,52 @@ const SideConfigSchema = z.object({
|
|
|
12
26
|
port: z.number().optional(),
|
|
13
27
|
});
|
|
14
28
|
|
|
29
|
+
/** Method-specific non-secret settings (host, paths, namespace, image name, etc.). */
|
|
30
|
+
const MethodConfigSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
deploymentType: z.enum(['server']).optional(),
|
|
33
|
+
frontendDeploymentType: z.enum(['server']).optional(),
|
|
34
|
+
backendDeploymentType: z.enum(['server']).optional(),
|
|
35
|
+
host: z.string().optional(),
|
|
36
|
+
user: z.string().optional(),
|
|
37
|
+
path: z.string().optional(),
|
|
38
|
+
deployPath: z.string().optional(),
|
|
39
|
+
keyPath: z.string().optional(),
|
|
40
|
+
sshPort: z.number().optional(),
|
|
41
|
+
ec2InstanceId: z.string().optional(),
|
|
42
|
+
awsRegion: z.string().optional(),
|
|
43
|
+
azureSubscriptionId: z.string().optional(),
|
|
44
|
+
azureResourceGroup: z.string().optional(),
|
|
45
|
+
azureVmName: z.string().optional(),
|
|
46
|
+
gcpProjectId: z.string().optional(),
|
|
47
|
+
gcpZone: z.string().optional(),
|
|
48
|
+
gcpInstanceName: z.string().optional(),
|
|
49
|
+
kubeconfig: z.string().optional(),
|
|
50
|
+
kubeContext: z.string().optional(),
|
|
51
|
+
kubeNamespace: z.string().optional(),
|
|
52
|
+
dockerImageName: z.string().optional(),
|
|
53
|
+
dockerRegistryUrl: z.string().optional(),
|
|
54
|
+
dockerHost: z.string().optional(),
|
|
55
|
+
healthCheckUrl: z.string().optional(),
|
|
56
|
+
appName: z.string().optional(),
|
|
57
|
+
framework: z.string().optional(),
|
|
58
|
+
port: z.number().optional(),
|
|
59
|
+
frontendDeployPath: z.string().optional(),
|
|
60
|
+
backendDeployPath: z.string().optional(),
|
|
61
|
+
})
|
|
62
|
+
.passthrough();
|
|
63
|
+
|
|
64
|
+
const DEPLOY_METHODS = ['ssh', 'docker', 'ec2', 'azure-vm', 'gcp-vm', 'kubernetes'];
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Per-environment entry. Storage (providers list / credentials) stays project-wide
|
|
68
|
+
* in this pass — per-env storage overrides are a future extension.
|
|
69
|
+
*/
|
|
15
70
|
const EnvironmentSchema = z.object({
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
host: z.string().optional(),
|
|
21
|
-
user: z.string().optional(),
|
|
22
|
-
path: z.string().optional(),
|
|
23
|
-
deployPath: z.string().optional(),
|
|
24
|
-
keyPath: z.string().optional(),
|
|
25
|
-
sshPort: z.number().optional(),
|
|
26
|
-
ec2InstanceId: z.string().optional(),
|
|
27
|
-
awsRegion: z.string().optional(),
|
|
28
|
-
azureSubscriptionId: z.string().optional(),
|
|
29
|
-
azureResourceGroup: z.string().optional(),
|
|
30
|
-
azureVmName: z.string().optional(),
|
|
31
|
-
gcpProjectId: z.string().optional(),
|
|
32
|
-
gcpZone: z.string().optional(),
|
|
33
|
-
gcpInstanceName: z.string().optional(),
|
|
34
|
-
kubeconfig: z.string().optional(),
|
|
35
|
-
kubeContext: z.string().optional(),
|
|
36
|
-
kubeNamespace: z.string().optional(),
|
|
37
|
-
dockerImageName: z.string().optional(),
|
|
38
|
-
dockerRegistryUrl: z.string().optional(),
|
|
39
|
-
dockerHost: z.string().optional(),
|
|
40
|
-
appName: z.string().optional(),
|
|
41
|
-
framework: z.string().optional(),
|
|
42
|
-
port: z.number().optional(),
|
|
43
|
-
frontendDeployPath: z.string().optional(),
|
|
44
|
-
backendDeployPath: z.string().optional(),
|
|
71
|
+
enabled: z.boolean().default(true),
|
|
72
|
+
method: z.string(),
|
|
73
|
+
trigger: z.enum(['push', 'manual']).default('manual'),
|
|
74
|
+
config: MethodConfigSchema.default({}),
|
|
45
75
|
});
|
|
46
76
|
|
|
47
77
|
const ConfigSchema = z.object({
|
|
@@ -60,8 +90,21 @@ const ConfigSchema = z.object({
|
|
|
60
90
|
docker: z.boolean().default(false),
|
|
61
91
|
artifact: z.boolean().default(true),
|
|
62
92
|
storage: z.array(z.string()).default(['local']),
|
|
63
|
-
|
|
93
|
+
/** @deprecated Prefer environments.*.enabled + defaultEnvironment; kept optional for in-memory BC. */
|
|
94
|
+
deploy: z.array(z.string()).optional().default([]),
|
|
64
95
|
environments: z.record(EnvironmentSchema).default({}),
|
|
96
|
+
defaultEnvironment: z.string().optional(),
|
|
97
|
+
/**
|
|
98
|
+
* Environment that keeps unprefixed CI secrets (SSH_HOST, not DEVELOPMENT_SSH_HOST).
|
|
99
|
+
* Set when the first environment is created; never silently changed when more envs are added.
|
|
100
|
+
*/
|
|
101
|
+
unprefixedSecretEnvironment: z.string().optional(),
|
|
102
|
+
/**
|
|
103
|
+
* Set when a project is created via init or when a legacy flat config is silently
|
|
104
|
+
* migrated. Tells ensureLegacyHistoryCopiedToDefaultEnv not to copy the build catalog
|
|
105
|
+
* into the default env (multi-env-native projects never had pre-multi-env deploy history).
|
|
106
|
+
*/
|
|
107
|
+
legacyHistoryMigrated: z.boolean().optional(),
|
|
65
108
|
healthCheck: z
|
|
66
109
|
.object({
|
|
67
110
|
url: z.string().default(''),
|
|
@@ -93,9 +136,214 @@ const ConfigSchema = z.object({
|
|
|
93
136
|
});
|
|
94
137
|
|
|
95
138
|
/** @typedef {z.infer<typeof ConfigSchema>} DeployHubConfig */
|
|
139
|
+
/** @typedef {z.infer<typeof EnvironmentSchema>} EnvironmentEntry */
|
|
140
|
+
/** @typedef {z.infer<typeof MethodConfigSchema>} MethodConfig */
|
|
96
141
|
|
|
97
142
|
const CONFIG_FILENAME = 'deployhub.config.json';
|
|
98
143
|
|
|
144
|
+
/** Top-level keys that indicated a pre-environments flat deploy config. */
|
|
145
|
+
const FLAT_DEPLOY_KEYS = [
|
|
146
|
+
'type',
|
|
147
|
+
'method',
|
|
148
|
+
'host',
|
|
149
|
+
'user',
|
|
150
|
+
'path',
|
|
151
|
+
'deployPath',
|
|
152
|
+
'keyPath',
|
|
153
|
+
'sshPort',
|
|
154
|
+
'ec2InstanceId',
|
|
155
|
+
'awsRegion',
|
|
156
|
+
'azureSubscriptionId',
|
|
157
|
+
'azureResourceGroup',
|
|
158
|
+
'azureVmName',
|
|
159
|
+
'gcpProjectId',
|
|
160
|
+
'gcpZone',
|
|
161
|
+
'gcpInstanceName',
|
|
162
|
+
'kubeconfig',
|
|
163
|
+
'kubeContext',
|
|
164
|
+
'kubeNamespace',
|
|
165
|
+
'dockerImageName',
|
|
166
|
+
'dockerRegistryUrl',
|
|
167
|
+
'dockerHost',
|
|
168
|
+
'appName',
|
|
169
|
+
'frontendDeployPath',
|
|
170
|
+
'backendDeployPath',
|
|
171
|
+
'deploymentType',
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* @param {unknown} entry
|
|
176
|
+
* @returns {boolean}
|
|
177
|
+
*/
|
|
178
|
+
// isNewEnvironmentShape imported from ./environments.js
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* @param {Record<string, unknown>} entry
|
|
182
|
+
* @returns {Record<string, unknown>}
|
|
183
|
+
*/
|
|
184
|
+
function extractMethodConfig(entry) {
|
|
185
|
+
/** @type {Record<string, unknown>} */
|
|
186
|
+
const config = { ...(entry.config && typeof entry.config === 'object' ? entry.config : {}) };
|
|
187
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
188
|
+
if (
|
|
189
|
+
key === 'enabled' ||
|
|
190
|
+
key === 'method' ||
|
|
191
|
+
key === 'trigger' ||
|
|
192
|
+
key === 'config' ||
|
|
193
|
+
key === 'type'
|
|
194
|
+
) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (value !== undefined) {
|
|
198
|
+
config[key] = value;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return config;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* @param {Record<string, unknown>} raw
|
|
206
|
+
* @returns {boolean}
|
|
207
|
+
*/
|
|
208
|
+
export function needsConfigMigration(raw) {
|
|
209
|
+
if (!raw || typeof raw !== 'object') return false;
|
|
210
|
+
|
|
211
|
+
const envs = raw.environments;
|
|
212
|
+
if (!envs || typeof envs !== 'object' || Array.isArray(envs)) {
|
|
213
|
+
return FLAT_DEPLOY_KEYS.some((k) => raw[k] !== undefined);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const entries = Object.values(envs);
|
|
217
|
+
if (entries.some((e) => e && typeof e === 'object' && !isNewEnvironmentShape(e))) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Legacy deploy[] without defaultEnvironment (even when env shapes are already new)
|
|
222
|
+
if (Array.isArray(raw.deploy) && raw.deploy.length > 0 && !raw.defaultEnvironment) {
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (entries.length > 0 && !raw.defaultEnvironment) {
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Migrate legacy flat / type-at-top-level environment configs to
|
|
235
|
+
* `{ enabled, method, trigger, config }` + `defaultEnvironment`.
|
|
236
|
+
* Idempotent: already-migrated configs are returned unchanged.
|
|
237
|
+
*
|
|
238
|
+
* @param {Record<string, unknown>} raw
|
|
239
|
+
* @returns {{ config: Record<string, unknown>, migrated: boolean, reason: string|null }}
|
|
240
|
+
*/
|
|
241
|
+
export function migrateConfigToEnvironments(raw) {
|
|
242
|
+
if (!needsConfigMigration(raw)) {
|
|
243
|
+
return { config: raw, migrated: false, reason: null };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** @type {Record<string, unknown>} */
|
|
247
|
+
const next = { ...raw };
|
|
248
|
+
const legacyDeploy = Array.isArray(raw.deploy) ? raw.deploy.map(String) : [];
|
|
249
|
+
|
|
250
|
+
/** @type {Record<string, Record<string, unknown>>} */
|
|
251
|
+
const newEnvironments = {};
|
|
252
|
+
|
|
253
|
+
const rawEnvs =
|
|
254
|
+
raw.environments && typeof raw.environments === 'object' && !Array.isArray(raw.environments)
|
|
255
|
+
? /** @type {Record<string, Record<string, unknown>>} */ (raw.environments)
|
|
256
|
+
: null;
|
|
257
|
+
|
|
258
|
+
if (rawEnvs && Object.keys(rawEnvs).length > 0) {
|
|
259
|
+
for (const [name, entry] of Object.entries(rawEnvs)) {
|
|
260
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
261
|
+
if (isNewEnvironmentShape(entry)) {
|
|
262
|
+
newEnvironments[name] = {
|
|
263
|
+
enabled: entry.enabled !== false,
|
|
264
|
+
method: String(entry.method),
|
|
265
|
+
trigger: entry.trigger === 'push' ? 'push' : 'manual',
|
|
266
|
+
config: extractMethodConfig(entry),
|
|
267
|
+
};
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const method = String(entry.method || entry.type || 'ssh');
|
|
272
|
+
const enabled =
|
|
273
|
+
legacyDeploy.length === 0 ? true : legacyDeploy.includes(name);
|
|
274
|
+
newEnvironments[name] = {
|
|
275
|
+
enabled,
|
|
276
|
+
method,
|
|
277
|
+
trigger: entry.trigger === 'push' ? 'push' : 'manual',
|
|
278
|
+
config: extractMethodConfig(entry),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
} else {
|
|
282
|
+
// Truly flat single-environment config (method/type + settings at top level).
|
|
283
|
+
const method = String(raw.method || raw.type || 'ssh');
|
|
284
|
+
/** @type {Record<string, unknown>} */
|
|
285
|
+
const flatConfig = {};
|
|
286
|
+
for (const key of FLAT_DEPLOY_KEYS) {
|
|
287
|
+
if (key === 'type' || key === 'method') continue;
|
|
288
|
+
if (raw[key] !== undefined) flatConfig[key] = raw[key];
|
|
289
|
+
}
|
|
290
|
+
newEnvironments.default = {
|
|
291
|
+
enabled: true,
|
|
292
|
+
method,
|
|
293
|
+
trigger: 'manual',
|
|
294
|
+
config: flatConfig,
|
|
295
|
+
};
|
|
296
|
+
for (const key of FLAT_DEPLOY_KEYS) {
|
|
297
|
+
delete next[key];
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
let defaultEnvironment =
|
|
302
|
+
typeof raw.defaultEnvironment === 'string' && raw.defaultEnvironment
|
|
303
|
+
? raw.defaultEnvironment
|
|
304
|
+
: null;
|
|
305
|
+
|
|
306
|
+
if (!defaultEnvironment || !newEnvironments[defaultEnvironment]) {
|
|
307
|
+
if (legacyDeploy[0] && newEnvironments[legacyDeploy[0]]) {
|
|
308
|
+
defaultEnvironment = legacyDeploy[0];
|
|
309
|
+
} else if (newEnvironments.default) {
|
|
310
|
+
defaultEnvironment = 'default';
|
|
311
|
+
} else if (newEnvironments.production) {
|
|
312
|
+
defaultEnvironment = 'production';
|
|
313
|
+
} else {
|
|
314
|
+
const enabledNames = Object.entries(newEnvironments)
|
|
315
|
+
.filter(([, e]) => e.enabled !== false)
|
|
316
|
+
.map(([n]) => n);
|
|
317
|
+
defaultEnvironment =
|
|
318
|
+
enabledNames[0] || Object.keys(newEnvironments)[0] || 'default';
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (!newEnvironments[defaultEnvironment] && Object.keys(newEnvironments).length === 0) {
|
|
323
|
+
newEnvironments[defaultEnvironment] = {
|
|
324
|
+
enabled: true,
|
|
325
|
+
method: 'ssh',
|
|
326
|
+
trigger: 'manual',
|
|
327
|
+
config: {},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
next.environments = newEnvironments;
|
|
332
|
+
next.defaultEnvironment = defaultEnvironment;
|
|
333
|
+
if (!next.unprefixedSecretEnvironment) {
|
|
334
|
+
next.unprefixedSecretEnvironment = defaultEnvironment;
|
|
335
|
+
}
|
|
336
|
+
// deploy[] is superseded by enabled + defaultEnvironment
|
|
337
|
+
delete next.deploy;
|
|
338
|
+
|
|
339
|
+
const reason =
|
|
340
|
+
!rawEnvs || Object.keys(rawEnvs).length === 0
|
|
341
|
+
? 'flat'
|
|
342
|
+
: 'legacy-environments';
|
|
343
|
+
|
|
344
|
+
return { config: next, migrated: true, reason };
|
|
345
|
+
}
|
|
346
|
+
|
|
99
347
|
/**
|
|
100
348
|
* @param {string} [cwd]
|
|
101
349
|
* @returns {string}
|
|
@@ -104,6 +352,20 @@ export function getConfigPath(cwd = process.cwd()) {
|
|
|
104
352
|
return path.join(cwd, CONFIG_FILENAME);
|
|
105
353
|
}
|
|
106
354
|
|
|
355
|
+
/**
|
|
356
|
+
* @param {DeployHubConfig} config
|
|
357
|
+
*/
|
|
358
|
+
export function validateConfigConsistency(config) {
|
|
359
|
+
const envs = config.environments || {};
|
|
360
|
+
const def = config.defaultEnvironment;
|
|
361
|
+
if (typeof def === 'string' && def && !envs[def]) {
|
|
362
|
+
throw new Error(
|
|
363
|
+
`defaultEnvironment "${def}" is not defined in environments. ` +
|
|
364
|
+
'Fix deployhub.config.json (typo?) or run deployhub env list.'
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
107
369
|
/**
|
|
108
370
|
* @param {string} [cwd]
|
|
109
371
|
* @returns {Promise<DeployHubConfig>}
|
|
@@ -116,7 +378,43 @@ export async function loadConfig(cwd = process.cwd()) {
|
|
|
116
378
|
);
|
|
117
379
|
}
|
|
118
380
|
const raw = await fs.readJson(configPath);
|
|
119
|
-
const
|
|
381
|
+
const { config: migratedRaw, migrated, reason } = migrateConfigToEnvironments(raw);
|
|
382
|
+
|
|
383
|
+
if (migrated) {
|
|
384
|
+
const log = createLogger('config');
|
|
385
|
+
if (reason === 'flat') {
|
|
386
|
+
log.warn('Old single-environment config detected.');
|
|
387
|
+
log.warn(' Migrating to environments.default using your existing settings.');
|
|
388
|
+
log.warn(
|
|
389
|
+
' Nothing changes in behavior — this just adds room for more environments later.'
|
|
390
|
+
);
|
|
391
|
+
} else {
|
|
392
|
+
log.warn('Legacy environment config format detected.');
|
|
393
|
+
log.warn(
|
|
394
|
+
' Migrating to enabled/method/trigger/config shape (defaultEnvironment set).'
|
|
395
|
+
);
|
|
396
|
+
log.warn(
|
|
397
|
+
' Nothing changes in behavior — this just adds room for more environments later.'
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
await fs.writeJson(configPath, migratedRaw, { spaces: 2 });
|
|
401
|
+
|
|
402
|
+
const storage = /** @type {string[]|undefined} */ (migratedRaw.storage);
|
|
403
|
+
if (
|
|
404
|
+
Array.isArray(storage) &&
|
|
405
|
+
storage.length > 0 &&
|
|
406
|
+
migratedRaw.project &&
|
|
407
|
+
migratedRaw.defaultEnvironment
|
|
408
|
+
) {
|
|
409
|
+
await ensureLegacyHistoryCopiedToDefaultEnv(
|
|
410
|
+
storage,
|
|
411
|
+
String(migratedRaw.project),
|
|
412
|
+
String(migratedRaw.defaultEnvironment)
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const parsed = ConfigSchema.parse(migratedRaw);
|
|
120
418
|
|
|
121
419
|
if (!parsed.framework && parsed.projectType === 'frontend') {
|
|
122
420
|
parsed.framework = 'node';
|
|
@@ -131,6 +429,27 @@ export async function loadConfig(cwd = process.cwd()) {
|
|
|
131
429
|
parsed.version = '0.0.0';
|
|
132
430
|
}
|
|
133
431
|
|
|
432
|
+
if (!parsed.defaultEnvironment) {
|
|
433
|
+
parsed.defaultEnvironment = resolveDefaultEnvironmentName(parsed) || undefined;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Grandfather unprefixed CI secrets for the original environment (in-memory;
|
|
437
|
+
// persisted on next saveConfig / env add / migration rewrite).
|
|
438
|
+
if (!parsed.unprefixedSecretEnvironment) {
|
|
439
|
+
const envNames = Object.keys(parsed.environments || {});
|
|
440
|
+
if (envNames.length >= 1) {
|
|
441
|
+
parsed.unprefixedSecretEnvironment =
|
|
442
|
+
parsed.defaultEnvironment && parsed.environments[parsed.defaultEnvironment]
|
|
443
|
+
? parsed.defaultEnvironment
|
|
444
|
+
: envNames[0];
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Keep deploy[] in sync for code paths that still read it (pipeline, doctor, workflows).
|
|
449
|
+
parsed.deploy = getEnabledEnvironmentNames(parsed);
|
|
450
|
+
|
|
451
|
+
validateConfigConsistency(parsed);
|
|
452
|
+
|
|
134
453
|
return parsed;
|
|
135
454
|
}
|
|
136
455
|
|
|
@@ -140,7 +459,11 @@ export async function loadConfig(cwd = process.cwd()) {
|
|
|
140
459
|
*/
|
|
141
460
|
export async function saveConfig(config, cwd = process.cwd()) {
|
|
142
461
|
const configPath = getConfigPath(cwd);
|
|
143
|
-
|
|
462
|
+
/** @type {Record<string, unknown>} */
|
|
463
|
+
const toWrite = { ...config };
|
|
464
|
+
// Persist new shape only — deploy[] is derived at load time.
|
|
465
|
+
delete toWrite.deploy;
|
|
466
|
+
await fs.writeJson(configPath, toWrite, { spaces: 2 });
|
|
144
467
|
}
|
|
145
468
|
|
|
146
469
|
/**
|
|
@@ -171,4 +494,19 @@ export async function appendEnv(vars, cwd = process.cwd()) {
|
|
|
171
494
|
await fs.appendFile(envPath, `${separator}${lines.join('\n')}\n`);
|
|
172
495
|
}
|
|
173
496
|
|
|
174
|
-
export {
|
|
497
|
+
export {
|
|
498
|
+
ConfigSchema,
|
|
499
|
+
EnvironmentSchema,
|
|
500
|
+
MethodConfigSchema,
|
|
501
|
+
DEPLOY_METHODS,
|
|
502
|
+
isNewEnvironmentShape,
|
|
503
|
+
getEnvMethod,
|
|
504
|
+
getEnvSettings,
|
|
505
|
+
isEnvEnabled,
|
|
506
|
+
getEnvTrigger,
|
|
507
|
+
resolveDefaultEnvironmentName,
|
|
508
|
+
getEnabledEnvironmentNames,
|
|
509
|
+
buildEnvironmentEntry,
|
|
510
|
+
resolveEnvTargets,
|
|
511
|
+
mergeMethodSettingsIntoEnv,
|
|
512
|
+
};
|