@wyxos/zephyr 0.9.12 → 0.9.14
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 +20 -1
- package/package.json +1 -1
- package/src/application/configuration/deployment-groups.mjs +68 -0
- package/src/application/deploy/run-local-deployment-checks.mjs +4 -4
- package/src/application/deploy/run-preset-deployment.mjs +118 -0
- package/src/cli/options.mjs +17 -2
- package/src/config/project.mjs +54 -9
- package/src/deploy/local-repo.mjs +12 -4
- package/src/deploy/preflight.mjs +12 -3
- package/src/main.mjs +51 -50
- package/src/utils/command.mjs +27 -4
package/README.md
CHANGED
|
@@ -52,6 +52,9 @@ zephyr minor --skip-checks
|
|
|
52
52
|
# Deploy a configured app non-interactively
|
|
53
53
|
zephyr --non-interactive --preset wyxos-release --maintenance off
|
|
54
54
|
|
|
55
|
+
# Deploy several configured app presets as one grouped release
|
|
56
|
+
zephyr --non-interactive --group "Development v1-v4" --maintenance off --skip-versioning
|
|
57
|
+
|
|
55
58
|
# Configure a Laravel app target and verify SSH without deploying
|
|
56
59
|
zephyr --setup
|
|
57
60
|
|
|
@@ -95,7 +98,7 @@ For app deployments, interactive mode now requires a real interactive terminal.
|
|
|
95
98
|
Non-interactive mode is strict and is intended for already-configured projects:
|
|
96
99
|
|
|
97
100
|
- `--non-interactive` fails instead of prompting
|
|
98
|
-
- app deployments require `--preset <name>`
|
|
101
|
+
- app deployments require `--preset <name>` or `--group <name>`
|
|
99
102
|
- Laravel app deployments require either a saved preset maintenance preference, `--maintenance on|off`, or a resumable snapshot that already contains the choice
|
|
100
103
|
- pending deployment snapshots require either `--resume-pending` or `--discard-pending`
|
|
101
104
|
- stale remote locks are never auto-removed in non-interactive mode
|
|
@@ -133,6 +136,12 @@ Recommended pattern for app deployments:
|
|
|
133
136
|
zephyr --non-interactive --json --preset wyxos-release --maintenance off
|
|
134
137
|
```
|
|
135
138
|
|
|
139
|
+
Recommended pattern for grouped app deployments:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
zephyr --non-interactive --json --group "Development v1-v4" --maintenance off --skip-versioning
|
|
143
|
+
```
|
|
144
|
+
|
|
136
145
|
Recommended pattern for package releases:
|
|
137
146
|
|
|
138
147
|
```bash
|
|
@@ -270,12 +279,22 @@ Deployment targets are stored per-project at `.zephyr/config.json`:
|
|
|
270
279
|
"sshUser": "forge",
|
|
271
280
|
"sshKey": "~/.ssh/id_rsa"
|
|
272
281
|
}
|
|
282
|
+
],
|
|
283
|
+
"groups": [
|
|
284
|
+
{
|
|
285
|
+
"name": "Production targets",
|
|
286
|
+
"presets": [
|
|
287
|
+
"prod-main"
|
|
288
|
+
]
|
|
289
|
+
}
|
|
273
290
|
]
|
|
274
291
|
}
|
|
275
292
|
```
|
|
276
293
|
|
|
277
294
|
Preset `options` capture repeatable deploy behavior so Zephyr can reuse the same maintenance, dirty-tree, and deploy-check preferences on later runs.
|
|
278
295
|
|
|
296
|
+
Deployment `groups` run the named presets in order. Zephyr performs local dependency validation once before the group starts, and after the first successful target for a branch it skips local checks and versioning for later targets on that same branch. Targets on a different branch are prepared separately.
|
|
297
|
+
|
|
279
298
|
### Project Directory Structure
|
|
280
299
|
|
|
281
300
|
Zephyr creates a `.zephyr/` directory in your project with:
|
package/package.json
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import {loadProjectConfig} from '../../config/project.mjs'
|
|
2
|
+
import {loadServers} from '../../config/servers.mjs'
|
|
3
|
+
import {ZephyrError} from '../../runtime/errors.mjs'
|
|
4
|
+
|
|
5
|
+
export function findDeploymentGroupByName(projectConfig, groupName) {
|
|
6
|
+
const groups = projectConfig?.groups ?? []
|
|
7
|
+
return groups.find((group) => group?.name === groupName) ?? null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function resolveDeploymentGroupPresetNames(projectConfig, groupName) {
|
|
11
|
+
const group = findDeploymentGroupByName(projectConfig, groupName)
|
|
12
|
+
|
|
13
|
+
if (!group) {
|
|
14
|
+
throw new ZephyrError(
|
|
15
|
+
`Zephyr cannot run deployment group "${groupName}" because it was not found in .zephyr/config.json.`,
|
|
16
|
+
{code: 'ZEPHYR_DEPLOYMENT_GROUP_NOT_FOUND'}
|
|
17
|
+
)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const presetNames = Array.isArray(group.presets)
|
|
21
|
+
? group.presets.filter((presetName) => typeof presetName === 'string' && presetName.trim().length > 0)
|
|
22
|
+
: []
|
|
23
|
+
|
|
24
|
+
if (presetNames.length === 0) {
|
|
25
|
+
throw new ZephyrError(
|
|
26
|
+
`Zephyr cannot run deployment group "${groupName}" because it has no presets.`,
|
|
27
|
+
{code: 'ZEPHYR_DEPLOYMENT_GROUP_INVALID'}
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const configuredPresetNames = new Set((projectConfig?.presets ?? []).map((preset) => preset?.name).filter(Boolean))
|
|
32
|
+
const missingPresetName = presetNames.find((presetName) => !configuredPresetNames.has(presetName))
|
|
33
|
+
|
|
34
|
+
if (missingPresetName) {
|
|
35
|
+
throw new ZephyrError(
|
|
36
|
+
`Zephyr cannot run deployment group "${groupName}" because preset "${missingPresetName}" was not found in .zephyr/config.json.`,
|
|
37
|
+
{code: 'ZEPHYR_DEPLOYMENT_GROUP_INVALID'}
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return presetNames
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function resolveDeploymentGroup(rootDir, {
|
|
45
|
+
groupName,
|
|
46
|
+
logSuccess,
|
|
47
|
+
logWarning,
|
|
48
|
+
strict = true,
|
|
49
|
+
allowMigration = false
|
|
50
|
+
} = {}) {
|
|
51
|
+
const servers = await loadServers({
|
|
52
|
+
logSuccess,
|
|
53
|
+
logWarning,
|
|
54
|
+
strict,
|
|
55
|
+
allowMigration
|
|
56
|
+
})
|
|
57
|
+
const projectConfig = await loadProjectConfig(rootDir, servers, {
|
|
58
|
+
logSuccess,
|
|
59
|
+
logWarning,
|
|
60
|
+
strict,
|
|
61
|
+
allowMigration
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
name: groupName,
|
|
66
|
+
presetNames: resolveDeploymentGroupPresetNames(projectConfig, groupName)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as localRepo from '../../deploy/local-repo.mjs'
|
|
2
2
|
import * as preflight from '../../deploy/preflight.mjs'
|
|
3
|
-
import {commandExists} from '../../utils/command.mjs'
|
|
3
|
+
import {commandExists, formatCommandError} from '../../utils/command.mjs'
|
|
4
4
|
|
|
5
5
|
async function getGitStatus(rootDir, {runCommandCapture} = {}) {
|
|
6
6
|
return await localRepo.getGitStatus(rootDir, {runCommandCapture})
|
|
@@ -98,7 +98,7 @@ async function runLocalLaravelBuild(rootDir, {runCommand, logProcessing, logSucc
|
|
|
98
98
|
)
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
-
throw new Error(`Local frontend build failed. Fix build failures before deploying.\n${error
|
|
101
|
+
throw new Error(`Local frontend build failed. Fix build failures before deploying.\n${formatCommandError(error)}`)
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
@@ -106,7 +106,7 @@ async function runLocalLaravelTests(rootDir, {runCommand, logProcessing, logSucc
|
|
|
106
106
|
logProcessing?.('Running Laravel tests locally...')
|
|
107
107
|
|
|
108
108
|
try {
|
|
109
|
-
await runCommand(testCommand.command, testCommand.args, {cwd: rootDir})
|
|
109
|
+
await runCommand(testCommand.command, testCommand.args, {cwd: rootDir, capture: true})
|
|
110
110
|
logSuccess?.('Local tests passed.')
|
|
111
111
|
} catch (error) {
|
|
112
112
|
if (error.code === 'ENOENT') {
|
|
@@ -116,7 +116,7 @@ async function runLocalLaravelTests(rootDir, {runCommand, logProcessing, logSucc
|
|
|
116
116
|
)
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
-
throw new Error(`Local tests failed. Fix test failures before deploying.\n${error
|
|
119
|
+
throw new Error(`Local tests failed. Fix test failures before deploying.\n${formatCommandError(error)}`)
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import {mergeDeployOptions} from '../../config/preset-options.mjs'
|
|
2
|
+
import {selectDeploymentTarget} from '../configuration/select-deployment-target.mjs'
|
|
3
|
+
import {resolvePendingSnapshot} from './resolve-pending-snapshot.mjs'
|
|
4
|
+
import {runDeployment} from './run-deployment.mjs'
|
|
5
|
+
|
|
6
|
+
async function runRemoteTasks(config, options = {}) {
|
|
7
|
+
return await runDeployment(config, {
|
|
8
|
+
...options,
|
|
9
|
+
context: options.context
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function mergePresetExecutionMode(executionMode, presetState) {
|
|
14
|
+
if (!presetState) {
|
|
15
|
+
return executionMode
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const effectiveDeployOptions = mergeDeployOptions(executionMode, presetState.options)
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
...executionMode,
|
|
22
|
+
presetName: presetState.name,
|
|
23
|
+
...effectiveDeployOptions,
|
|
24
|
+
skipChecks: executionMode.skipChecks === true ||
|
|
25
|
+
(effectiveDeployOptions.skipTests === true && effectiveDeployOptions.skipLint === true)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function reusePreparedBranchExecutionMode(executionMode, deploymentConfig, preparedBranches, logProcessing) {
|
|
30
|
+
if (!preparedBranches?.has(deploymentConfig.branch)) {
|
|
31
|
+
return executionMode
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
logProcessing?.(
|
|
35
|
+
`Reusing completed local preparation for branch ${deploymentConfig.branch}; skipping local checks and versioning for this target.`
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
...executionMode,
|
|
40
|
+
skipChecks: true,
|
|
41
|
+
skipTests: true,
|
|
42
|
+
skipLint: true,
|
|
43
|
+
skipVersioning: true
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function runPresetDeployment({
|
|
48
|
+
rootDir,
|
|
49
|
+
configurationService,
|
|
50
|
+
appContext,
|
|
51
|
+
runPrompt,
|
|
52
|
+
logProcessing,
|
|
53
|
+
logSuccess,
|
|
54
|
+
logWarning,
|
|
55
|
+
emitEvent,
|
|
56
|
+
baseExecutionMode,
|
|
57
|
+
presetName,
|
|
58
|
+
versionArg,
|
|
59
|
+
preparedBranches = null
|
|
60
|
+
} = {}) {
|
|
61
|
+
let targetExecutionMode = {
|
|
62
|
+
...baseExecutionMode,
|
|
63
|
+
presetName
|
|
64
|
+
}
|
|
65
|
+
appContext.executionMode = targetExecutionMode
|
|
66
|
+
|
|
67
|
+
const {deploymentConfig, presetState} = await selectDeploymentTarget(rootDir, {
|
|
68
|
+
configurationService,
|
|
69
|
+
runPrompt,
|
|
70
|
+
logProcessing,
|
|
71
|
+
logSuccess,
|
|
72
|
+
logWarning,
|
|
73
|
+
emitEvent,
|
|
74
|
+
executionMode: targetExecutionMode,
|
|
75
|
+
promptPresetOptions: targetExecutionMode.setup !== true
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
targetExecutionMode = mergePresetExecutionMode(targetExecutionMode, presetState)
|
|
79
|
+
appContext.executionMode = targetExecutionMode
|
|
80
|
+
|
|
81
|
+
if (presetState) {
|
|
82
|
+
await presetState.applyExecutionMode(targetExecutionMode)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
targetExecutionMode = reusePreparedBranchExecutionMode(
|
|
86
|
+
targetExecutionMode,
|
|
87
|
+
deploymentConfig,
|
|
88
|
+
preparedBranches,
|
|
89
|
+
logProcessing
|
|
90
|
+
)
|
|
91
|
+
appContext.executionMode = targetExecutionMode
|
|
92
|
+
|
|
93
|
+
const snapshotToUse = targetExecutionMode.setup
|
|
94
|
+
? null
|
|
95
|
+
: await resolvePendingSnapshot(rootDir, deploymentConfig, {
|
|
96
|
+
runPrompt,
|
|
97
|
+
logProcessing,
|
|
98
|
+
logWarning,
|
|
99
|
+
executionMode: targetExecutionMode
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
const deploymentOptions = {
|
|
103
|
+
rootDir,
|
|
104
|
+
snapshot: snapshotToUse,
|
|
105
|
+
versionArg,
|
|
106
|
+
context: appContext
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (presetState !== undefined) {
|
|
110
|
+
deploymentOptions.presetState = presetState
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await runRemoteTasks(deploymentConfig, deploymentOptions)
|
|
114
|
+
|
|
115
|
+
preparedBranches?.add(deploymentConfig.branch)
|
|
116
|
+
|
|
117
|
+
return targetExecutionMode
|
|
118
|
+
}
|
package/src/cli/options.mjs
CHANGED
|
@@ -42,6 +42,7 @@ export function parseCliOptions(args = process.argv.slice(2)) {
|
|
|
42
42
|
.option('--json', 'Emit NDJSON events to stdout. Requires --non-interactive.')
|
|
43
43
|
.option('--setup', 'Configure an app deployment target and verify SSH connectivity without deploying.')
|
|
44
44
|
.option('--preset <name>', 'Preset name to use for non-interactive app deployments.')
|
|
45
|
+
.option('--group <name>', 'Deployment group name to run multiple app deployment presets in sequence.')
|
|
45
46
|
.option('--resume-pending', 'Resume a saved pending deployment snapshot without prompting.')
|
|
46
47
|
.option('--discard-pending', 'Discard a saved pending deployment snapshot without prompting.')
|
|
47
48
|
.option('--maintenance <mode>', 'Laravel maintenance mode policy for app deployments (on|off).')
|
|
@@ -87,6 +88,7 @@ export function parseCliOptions(args = process.argv.slice(2)) {
|
|
|
87
88
|
json: Boolean(options.json),
|
|
88
89
|
setup: Boolean(options.setup),
|
|
89
90
|
presetName: options.preset ?? null,
|
|
91
|
+
groupName: options.group ?? null,
|
|
90
92
|
resumePending: Boolean(options.resumePending),
|
|
91
93
|
discardPending: Boolean(options.discardPending),
|
|
92
94
|
maintenanceMode: normalizeMaintenanceMode(options.maintenance),
|
|
@@ -132,6 +134,7 @@ export function validateCliOptions(options = {}) {
|
|
|
132
134
|
setup = false,
|
|
133
135
|
thenDeploy = null,
|
|
134
136
|
presetName = null,
|
|
137
|
+
groupName = null,
|
|
135
138
|
resumePending = false,
|
|
136
139
|
discardPending = false,
|
|
137
140
|
maintenanceMode = null,
|
|
@@ -198,6 +201,10 @@ export function validateCliOptions(options = {}) {
|
|
|
198
201
|
throw new InvalidCliOptionsError('--preset is only valid for app deployments.')
|
|
199
202
|
}
|
|
200
203
|
|
|
204
|
+
if (groupName) {
|
|
205
|
+
throw new InvalidCliOptionsError('--group is only valid for app deployments.')
|
|
206
|
+
}
|
|
207
|
+
|
|
201
208
|
if (resumePending || discardPending) {
|
|
202
209
|
throw new InvalidCliOptionsError('--resume-pending and --discard-pending are only valid for app deployments.')
|
|
203
210
|
}
|
|
@@ -210,11 +217,19 @@ export function validateCliOptions(options = {}) {
|
|
|
210
217
|
throw new InvalidCliOptionsError('--skip-build and --skip-deploy are only valid for node/vue release workflows.')
|
|
211
218
|
}
|
|
212
219
|
|
|
220
|
+
if (presetName && groupName) {
|
|
221
|
+
throw new InvalidCliOptionsError('Use either --preset <name> or --group <name>, not both.')
|
|
222
|
+
}
|
|
223
|
+
|
|
213
224
|
if (setup) {
|
|
214
225
|
if (versionArg) {
|
|
215
226
|
throw new InvalidCliOptionsError('--setup cannot be used with a version or bump argument.')
|
|
216
227
|
}
|
|
217
228
|
|
|
229
|
+
if (groupName) {
|
|
230
|
+
throw new InvalidCliOptionsError('--setup cannot be used with --group.')
|
|
231
|
+
}
|
|
232
|
+
|
|
218
233
|
if (resumePending || discardPending) {
|
|
219
234
|
throw new InvalidCliOptionsError('--setup cannot be used with pending deployment snapshot flags.')
|
|
220
235
|
}
|
|
@@ -232,8 +247,8 @@ export function validateCliOptions(options = {}) {
|
|
|
232
247
|
}
|
|
233
248
|
}
|
|
234
249
|
|
|
235
|
-
if (nonInteractive && !presetName) {
|
|
236
|
-
throw new InvalidCliOptionsError('--non-interactive app deployments require --preset <name>.')
|
|
250
|
+
if (nonInteractive && !presetName && !groupName) {
|
|
251
|
+
throw new InvalidCliOptionsError('--non-interactive app deployments require --preset <name> or --group <name>.')
|
|
237
252
|
}
|
|
238
253
|
}
|
|
239
254
|
|
package/src/config/project.mjs
CHANGED
|
@@ -105,6 +105,43 @@ export function migratePresets(presets, apps) {
|
|
|
105
105
|
return { presets: migrated, needsMigration }
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
export function migrateGroups(groups) {
|
|
109
|
+
if (!Array.isArray(groups)) {
|
|
110
|
+
return { groups: [], needsMigration: false }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let needsMigration = false
|
|
114
|
+
const migrated = groups.flatMap((group) => {
|
|
115
|
+
if (!group || typeof group !== 'object') {
|
|
116
|
+
needsMigration = true
|
|
117
|
+
return []
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const name = typeof group.name === 'string' ? group.name.trim() : ''
|
|
121
|
+
const rawPresets = Array.isArray(group.presets)
|
|
122
|
+
? group.presets
|
|
123
|
+
: Array.isArray(group.presetNames)
|
|
124
|
+
? group.presetNames
|
|
125
|
+
: []
|
|
126
|
+
const presets = rawPresets
|
|
127
|
+
.map((presetName) => typeof presetName === 'string' ? presetName.trim() : '')
|
|
128
|
+
.filter((presetName) => presetName.length > 0)
|
|
129
|
+
|
|
130
|
+
if (!name || presets.length === 0) {
|
|
131
|
+
needsMigration = true
|
|
132
|
+
return []
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (name !== group.name || !Array.isArray(group.presets) || presets.length !== rawPresets.length) {
|
|
136
|
+
needsMigration = true
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return [{ name, presets }]
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
return { groups: migrated, needsMigration }
|
|
143
|
+
}
|
|
144
|
+
|
|
108
145
|
export async function loadProjectConfig(rootDir, servers = [], {
|
|
109
146
|
logSuccess,
|
|
110
147
|
logWarning,
|
|
@@ -118,11 +155,13 @@ export async function loadProjectConfig(rootDir, servers = [], {
|
|
|
118
155
|
const data = JSON.parse(raw)
|
|
119
156
|
const apps = Array.isArray(data?.apps) ? data.apps : []
|
|
120
157
|
const presets = Array.isArray(data?.presets) ? data.presets : []
|
|
158
|
+
const groups = Array.isArray(data?.groups) ? data.groups : []
|
|
121
159
|
|
|
122
160
|
const { apps: migratedApps, needsMigration: appsNeedMigration } = migrateApps(apps, servers)
|
|
123
161
|
const { presets: migratedPresets, needsMigration: presetsNeedMigration } = migratePresets(presets, migratedApps)
|
|
162
|
+
const { groups: migratedGroups, needsMigration: groupsNeedMigration } = migrateGroups(groups)
|
|
124
163
|
|
|
125
|
-
if (appsNeedMigration || presetsNeedMigration) {
|
|
164
|
+
if (appsNeedMigration || presetsNeedMigration || groupsNeedMigration) {
|
|
126
165
|
if (!allowMigration) {
|
|
127
166
|
throw new ZephyrError(
|
|
128
167
|
'Zephyr cannot run non-interactively because .zephyr/config.json needs migration. Rerun interactively once to update the config.',
|
|
@@ -132,12 +171,13 @@ export async function loadProjectConfig(rootDir, servers = [], {
|
|
|
132
171
|
|
|
133
172
|
await saveProjectConfig(rootDir, {
|
|
134
173
|
apps: migratedApps,
|
|
135
|
-
presets: migratedPresets
|
|
174
|
+
presets: migratedPresets,
|
|
175
|
+
groups: migratedGroups
|
|
136
176
|
})
|
|
137
177
|
logSuccess?.('Migrated project configuration to use unique IDs.')
|
|
138
178
|
}
|
|
139
179
|
|
|
140
|
-
return { apps: migratedApps, presets: migratedPresets }
|
|
180
|
+
return { apps: migratedApps, presets: migratedPresets, groups: migratedGroups }
|
|
141
181
|
} catch (error) {
|
|
142
182
|
if (error.code === 'ENOENT') {
|
|
143
183
|
if (strict) {
|
|
@@ -147,7 +187,7 @@ export async function loadProjectConfig(rootDir, servers = [], {
|
|
|
147
187
|
)
|
|
148
188
|
}
|
|
149
189
|
|
|
150
|
-
return { apps: [], presets: [] }
|
|
190
|
+
return { apps: [], presets: [], groups: [] }
|
|
151
191
|
}
|
|
152
192
|
|
|
153
193
|
if (error instanceof ZephyrError) {
|
|
@@ -162,19 +202,24 @@ export async function loadProjectConfig(rootDir, servers = [], {
|
|
|
162
202
|
}
|
|
163
203
|
|
|
164
204
|
logWarning?.('Failed to read .zephyr/config.json, starting with an empty list of apps.')
|
|
165
|
-
return { apps: [], presets: [] }
|
|
205
|
+
return { apps: [], presets: [], groups: [] }
|
|
166
206
|
}
|
|
167
207
|
}
|
|
168
208
|
|
|
169
209
|
export async function saveProjectConfig(rootDir, config) {
|
|
170
210
|
const configDir = getProjectConfigDir(rootDir)
|
|
171
211
|
await ensureDirectory(configDir)
|
|
212
|
+
const payloadData = {
|
|
213
|
+
apps: config.apps ?? [],
|
|
214
|
+
presets: config.presets ?? []
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (Array.isArray(config.groups) && config.groups.length > 0) {
|
|
218
|
+
payloadData.groups = config.groups
|
|
219
|
+
}
|
|
172
220
|
|
|
173
221
|
const payload = JSON.stringify(
|
|
174
|
-
|
|
175
|
-
apps: config.apps ?? [],
|
|
176
|
-
presets: config.presets ?? []
|
|
177
|
-
},
|
|
222
|
+
payloadData,
|
|
178
223
|
null,
|
|
179
224
|
2
|
|
180
225
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { getCurrentBranch as getCurrentBranchImpl, getUpstreamRef as getUpstreamRefImpl } from '../utils/git.mjs'
|
|
2
2
|
import {hasPrePushHook} from './preflight.mjs'
|
|
3
3
|
import {gitCommitArgs, gitPushArgs} from '../utils/git-hooks.mjs'
|
|
4
|
+
import {formatCommandError} from '../utils/command.mjs'
|
|
4
5
|
import {
|
|
5
6
|
formatWorkingTreePreview,
|
|
6
7
|
parseWorkingTreeEntries,
|
|
@@ -207,6 +208,13 @@ async function commitAndPushPendingChanges(targetBranch, rootDir, {
|
|
|
207
208
|
await runCommand(command, args, { cwd })
|
|
208
209
|
return undefined
|
|
209
210
|
}
|
|
211
|
+
const runCapturedCommand = async (command, args) => {
|
|
212
|
+
try {
|
|
213
|
+
await runCommand(command, args, {cwd: rootDir, capture: true})
|
|
214
|
+
} catch (error) {
|
|
215
|
+
throw new Error(formatCommandError(error))
|
|
216
|
+
}
|
|
217
|
+
}
|
|
210
218
|
|
|
211
219
|
const suggestedCommitMessage = await suggestCommitMessage(rootDir, {
|
|
212
220
|
runCommand: captureAwareRunCommand,
|
|
@@ -244,10 +252,10 @@ async function commitAndPushPendingChanges(targetBranch, rootDir, {
|
|
|
244
252
|
}
|
|
245
253
|
|
|
246
254
|
logProcessing?.('Staging all pending changes before deployment...')
|
|
247
|
-
await
|
|
255
|
+
await runCapturedCommand('git', ['add', '-A'])
|
|
248
256
|
|
|
249
257
|
logProcessing?.('Committing pending changes before deployment...')
|
|
250
|
-
await
|
|
258
|
+
await runCapturedCommand('git', gitCommitArgs(['-m', message], {skipGitHooks}))
|
|
251
259
|
|
|
252
260
|
const prePushHookPresent = await hasPrePushHook(rootDir)
|
|
253
261
|
if (prePushHookPresent) {
|
|
@@ -259,10 +267,10 @@ async function commitAndPushPendingChanges(targetBranch, rootDir, {
|
|
|
259
267
|
}
|
|
260
268
|
|
|
261
269
|
try {
|
|
262
|
-
await
|
|
270
|
+
await runCapturedCommand('git', gitPushArgs(['origin', targetBranch], {skipGitHooks}))
|
|
263
271
|
} catch (error) {
|
|
264
272
|
if (prePushHookPresent) {
|
|
265
|
-
throw new Error(`Git push failed while the pre-push hook was running
|
|
273
|
+
throw new Error(`Git push failed while the pre-push hook was running.\n${error.message}`)
|
|
266
274
|
}
|
|
267
275
|
|
|
268
276
|
throw error
|
package/src/deploy/preflight.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
4
|
import {gitCommitArgs} from '../utils/git-hooks.mjs'
|
|
5
|
+
import {formatCommandError} from '../utils/command.mjs'
|
|
5
6
|
|
|
6
7
|
export async function hasPrePushHook(rootDir) {
|
|
7
8
|
const hookPaths = [
|
|
@@ -137,14 +138,22 @@ export async function runLinting(rootDir, {
|
|
|
137
138
|
|
|
138
139
|
if (selectedLintCommand.type === 'npm') {
|
|
139
140
|
logProcessing?.('Running npm lint...')
|
|
140
|
-
|
|
141
|
+
try {
|
|
142
|
+
await runCommand(selectedLintCommand.command, selectedLintCommand.args, {cwd: rootDir, capture: true})
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw new Error(`Linting failed. Fix lint failures before deploying.\n${formatCommandError(error)}`)
|
|
145
|
+
}
|
|
141
146
|
logSuccess?.('Linting completed.')
|
|
142
147
|
return true
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
if (selectedLintCommand.type === 'pint') {
|
|
146
151
|
logProcessing?.('Running Laravel Pint...')
|
|
147
|
-
|
|
152
|
+
try {
|
|
153
|
+
await runCommand(selectedLintCommand.command, selectedLintCommand.args, {cwd: rootDir, capture: true})
|
|
154
|
+
} catch (error) {
|
|
155
|
+
throw new Error(`Laravel Pint failed. Fix formatting failures before deploying.\n${formatCommandError(error)}`)
|
|
156
|
+
}
|
|
148
157
|
logSuccess?.('Linting completed.')
|
|
149
158
|
return true
|
|
150
159
|
}
|
|
@@ -166,7 +175,7 @@ export async function runBuild(rootDir, {
|
|
|
166
175
|
}
|
|
167
176
|
|
|
168
177
|
logProcessing?.('Running local frontend build...')
|
|
169
|
-
await runCommand(selectedBuildCommand.command, selectedBuildCommand.args, {cwd: rootDir})
|
|
178
|
+
await runCommand(selectedBuildCommand.command, selectedBuildCommand.args, {cwd: rootDir, capture: true})
|
|
170
179
|
logSuccess?.('Local frontend build completed.')
|
|
171
180
|
return true
|
|
172
181
|
}
|
package/src/main.mjs
CHANGED
|
@@ -11,12 +11,10 @@ import * as bootstrap from './project/bootstrap.mjs'
|
|
|
11
11
|
import {getErrorCode, ZephyrError} from './runtime/errors.mjs'
|
|
12
12
|
import {PROJECT_CONFIG_DIR} from './utils/paths.mjs'
|
|
13
13
|
import {writeStderrLine} from './utils/output.mjs'
|
|
14
|
-
import {mergeDeployOptions} from './config/preset-options.mjs'
|
|
15
14
|
import {createAppContext} from './runtime/app-context.mjs'
|
|
16
15
|
import {createConfigurationService} from './application/configuration/service.mjs'
|
|
17
|
-
import {
|
|
18
|
-
import {
|
|
19
|
-
import {runDeployment} from './application/deploy/run-deployment.mjs'
|
|
16
|
+
import {resolveDeploymentGroup} from './application/configuration/deployment-groups.mjs'
|
|
17
|
+
import {runPresetDeployment} from './application/deploy/run-preset-deployment.mjs'
|
|
20
18
|
import {assertLaravelSetupProject} from './application/deploy/verify-laravel-setup.mjs'
|
|
21
19
|
import {releasePackageThenDeployConsumer} from './application/consumer/release-package-then-deploy-consumer.mjs'
|
|
22
20
|
import {SKIP_GIT_HOOKS_WARNING} from './utils/git-hooks.mjs'
|
|
@@ -49,6 +47,7 @@ function normalizeMainOptions(firstArg = null, secondArg = null) {
|
|
|
49
47
|
json: firstArg.json === true,
|
|
50
48
|
setup: firstArg.setup === true,
|
|
51
49
|
presetName: firstArg.presetName ?? null,
|
|
50
|
+
groupName: firstArg.groupName ?? null,
|
|
52
51
|
resumePending: firstArg.resumePending === true,
|
|
53
52
|
discardPending: firstArg.discardPending === true,
|
|
54
53
|
maintenanceMode: firstArg.maintenanceMode ?? null,
|
|
@@ -95,6 +94,7 @@ function normalizeMainOptions(firstArg = null, secondArg = null) {
|
|
|
95
94
|
json: false,
|
|
96
95
|
setup: false,
|
|
97
96
|
presetName: null,
|
|
97
|
+
groupName: null,
|
|
98
98
|
resumePending: false,
|
|
99
99
|
discardPending: false,
|
|
100
100
|
maintenanceMode: null,
|
|
@@ -162,13 +162,6 @@ function assertInteractiveAppDeploySession({workflowType = null, executionMode =
|
|
|
162
162
|
)
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
-
async function runRemoteTasks(config, options = {}) {
|
|
166
|
-
return await runDeployment(config, {
|
|
167
|
-
...options,
|
|
168
|
-
context: options.context
|
|
169
|
-
})
|
|
170
|
-
}
|
|
171
|
-
|
|
172
165
|
async function main(optionsOrWorkflowType = null, versionArg = null) {
|
|
173
166
|
const options = normalizeMainOptions(optionsOrWorkflowType, versionArg)
|
|
174
167
|
const rootDir = process.cwd()
|
|
@@ -179,6 +172,7 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
|
|
|
179
172
|
workflow: resolveWorkflowName(options.workflowType),
|
|
180
173
|
setup: options.setup === true,
|
|
181
174
|
presetName: options.presetName,
|
|
175
|
+
groupName: options.groupName,
|
|
182
176
|
maintenanceMode: options.maintenanceMode,
|
|
183
177
|
autoCommit: options.autoCommit === true,
|
|
184
178
|
skipVersioning: options.skipVersioning === true,
|
|
@@ -226,6 +220,7 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
|
|
|
226
220
|
setup: currentExecutionMode.setup === true,
|
|
227
221
|
nonInteractive: currentExecutionMode.interactive === false,
|
|
228
222
|
presetName: currentExecutionMode.presetName,
|
|
223
|
+
groupName: currentExecutionMode.groupName,
|
|
229
224
|
maintenanceMode: currentExecutionMode.maintenanceMode,
|
|
230
225
|
autoCommit: currentExecutionMode.autoCommit === true,
|
|
231
226
|
skipVersioning: currentExecutionMode.skipVersioning === true,
|
|
@@ -371,59 +366,65 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
|
|
|
371
366
|
})
|
|
372
367
|
}
|
|
373
368
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
369
|
+
if (currentExecutionMode.groupName) {
|
|
370
|
+
const deploymentGroup = await resolveDeploymentGroup(rootDir, {
|
|
371
|
+
groupName: currentExecutionMode.groupName,
|
|
372
|
+
logSuccess,
|
|
373
|
+
logWarning,
|
|
374
|
+
strict: currentExecutionMode.interactive === false,
|
|
375
|
+
allowMigration: currentExecutionMode.interactive !== false
|
|
376
|
+
})
|
|
377
|
+
const preparedBranches = new Set()
|
|
378
|
+
const baseGroupExecutionMode = {...currentExecutionMode}
|
|
379
|
+
|
|
380
|
+
logProcessing?.(`Running deployment group "${deploymentGroup.name}" with ${deploymentGroup.presetNames.length} presets.`)
|
|
381
|
+
|
|
382
|
+
for (const [index, presetName] of deploymentGroup.presetNames.entries()) {
|
|
383
|
+
logProcessing?.(`\nDeploying group target ${index + 1}/${deploymentGroup.presetNames.length}: ${presetName}`)
|
|
384
|
+
currentExecutionMode = await runPresetDeployment({
|
|
385
|
+
rootDir,
|
|
386
|
+
configurationService,
|
|
387
|
+
appContext,
|
|
388
|
+
runPrompt,
|
|
389
|
+
logProcessing,
|
|
390
|
+
logSuccess,
|
|
391
|
+
logWarning,
|
|
392
|
+
emitEvent,
|
|
393
|
+
baseExecutionMode: baseGroupExecutionMode,
|
|
394
|
+
presetName,
|
|
395
|
+
versionArg: options.versionArg,
|
|
396
|
+
preparedBranches
|
|
397
|
+
})
|
|
393
398
|
}
|
|
394
|
-
|
|
395
|
-
await
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
? null
|
|
400
|
-
: await resolvePendingSnapshot(rootDir, deploymentConfig, {
|
|
399
|
+
} else {
|
|
400
|
+
currentExecutionMode = await runPresetDeployment({
|
|
401
|
+
rootDir,
|
|
402
|
+
configurationService,
|
|
403
|
+
appContext,
|
|
401
404
|
runPrompt,
|
|
402
405
|
logProcessing,
|
|
406
|
+
logSuccess,
|
|
403
407
|
logWarning,
|
|
404
|
-
|
|
408
|
+
emitEvent,
|
|
409
|
+
baseExecutionMode: currentExecutionMode,
|
|
410
|
+
presetName: currentExecutionMode.presetName,
|
|
411
|
+
versionArg: options.versionArg
|
|
405
412
|
})
|
|
406
|
-
|
|
407
|
-
await runRemoteTasks(deploymentConfig, {
|
|
408
|
-
rootDir,
|
|
409
|
-
snapshot: snapshotToUse,
|
|
410
|
-
versionArg: options.versionArg,
|
|
411
|
-
context: appContext,
|
|
412
|
-
presetState
|
|
413
|
-
})
|
|
413
|
+
}
|
|
414
414
|
|
|
415
415
|
emitEvent?.('run_completed', {
|
|
416
416
|
message: 'Zephyr workflow completed successfully.',
|
|
417
417
|
data: {
|
|
418
418
|
version: ZEPHYR_VERSION,
|
|
419
|
-
workflow: currentExecutionMode.workflow
|
|
419
|
+
workflow: currentExecutionMode.workflow,
|
|
420
|
+
groupName: currentExecutionMode.groupName
|
|
420
421
|
}
|
|
421
422
|
})
|
|
422
423
|
if (!currentExecutionMode.json) {
|
|
423
424
|
await notifyWorkflowResult({
|
|
424
425
|
status: 'success',
|
|
425
426
|
workflow: currentExecutionMode.workflow,
|
|
426
|
-
presetName: currentExecutionMode.presetName,
|
|
427
|
+
presetName: currentExecutionMode.groupName ?? currentExecutionMode.presetName,
|
|
427
428
|
rootDir
|
|
428
429
|
})
|
|
429
430
|
}
|
|
@@ -446,7 +447,7 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
|
|
|
446
447
|
await notifyWorkflowResult({
|
|
447
448
|
status: 'failure',
|
|
448
449
|
workflow: currentExecutionMode.workflow,
|
|
449
|
-
presetName: currentExecutionMode.presetName,
|
|
450
|
+
presetName: currentExecutionMode.groupName ?? currentExecutionMode.presetName,
|
|
450
451
|
rootDir,
|
|
451
452
|
message: error.message
|
|
452
453
|
})
|
|
@@ -456,4 +457,4 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
|
|
|
456
457
|
}
|
|
457
458
|
}
|
|
458
459
|
|
|
459
|
-
export {main
|
|
460
|
+
export {main}
|
package/src/utils/command.mjs
CHANGED
|
@@ -12,12 +12,11 @@ export function commandExists(command) {
|
|
|
12
12
|
const resolvedCommand = resolveCommandForPlatform(command)
|
|
13
13
|
|
|
14
14
|
// On Windows, use 'where', on Unix use 'which'
|
|
15
|
-
const checker = DEFAULT_IS_WINDOWS ? 'where' : 'which'
|
|
15
|
+
const checker = DEFAULT_IS_WINDOWS ? 'where.exe' : 'which'
|
|
16
16
|
|
|
17
17
|
try {
|
|
18
18
|
const result = spawnSync(checker, [resolvedCommand], {
|
|
19
|
-
stdio: ['ignore', 'pipe', 'ignore']
|
|
20
|
-
shell: DEFAULT_IS_WINDOWS
|
|
19
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
21
20
|
})
|
|
22
21
|
return result.status === 0
|
|
23
22
|
} catch {
|
|
@@ -67,7 +66,11 @@ function quoteForCmd(arg) {
|
|
|
67
66
|
return value
|
|
68
67
|
}
|
|
69
68
|
|
|
70
|
-
export async function runCommand(command, args, { cwd = process.cwd(), stdio = 'inherit' } = {}) {
|
|
69
|
+
export async function runCommand(command, args, { cwd = process.cwd(), stdio = 'inherit', capture = false } = {}) {
|
|
70
|
+
if (capture) {
|
|
71
|
+
return runCommandCapture(command, args, {cwd})
|
|
72
|
+
}
|
|
73
|
+
|
|
71
74
|
const resolvedCommand = resolveCommandForPlatform(command)
|
|
72
75
|
const useShell = isWindowsShellShim(resolvedCommand) || shouldUseShellOnWindows(command)
|
|
73
76
|
|
|
@@ -152,3 +155,23 @@ export async function runCommandCapture(command, args, { cwd = process.cwd() } =
|
|
|
152
155
|
})
|
|
153
156
|
}
|
|
154
157
|
|
|
158
|
+
export function formatCapturedCommandOutput(error) {
|
|
159
|
+
const sections = [
|
|
160
|
+
['stdout', error?.stdout],
|
|
161
|
+
['stderr', error?.stderr]
|
|
162
|
+
]
|
|
163
|
+
.map(([label, value]) => {
|
|
164
|
+
const text = typeof value === 'string' ? value.trim() : ''
|
|
165
|
+
return text ? `[${label}]\n${text}` : null
|
|
166
|
+
})
|
|
167
|
+
.filter(Boolean)
|
|
168
|
+
|
|
169
|
+
return sections.join('\n')
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function formatCommandError(error) {
|
|
173
|
+
const message = error?.message ?? String(error)
|
|
174
|
+
const output = formatCapturedCommandOutput(error)
|
|
175
|
+
|
|
176
|
+
return output ? `${message}\n${output}` : message
|
|
177
|
+
}
|