@wyxos/zephyr 0.3.2 → 0.3.3
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/package.json +1 -1
- package/src/application/deploy/build-remote-deployment-plan.mjs +152 -3
- package/src/application/deploy/execute-remote-deployment-plan.mjs +23 -8
- package/src/application/deploy/plan-laravel-deployment-tasks.mjs +27 -8
- package/src/application/deploy/prepare-local-deployment.mjs +9 -2
- package/src/application/deploy/run-deployment.mjs +66 -4
- package/src/application/deploy/run-local-deployment-checks.mjs +71 -11
- package/src/deploy/local-repo.mjs +51 -9
- package/src/deploy/preflight.mjs +49 -12
- package/src/main.mjs +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import {findPhpBinary} from '../../infrastructure/php/version.mjs'
|
|
2
2
|
import {planLaravelDeploymentTasks} from './plan-laravel-deployment-tasks.mjs'
|
|
3
3
|
|
|
4
|
+
const PRERENDERED_MAINTENANCE_VIEW = 'errors::503'
|
|
5
|
+
const PRERENDERED_MAINTENANCE_FILE = 'resources/views/errors/503.blade.php'
|
|
6
|
+
|
|
4
7
|
async function detectRemoteLaravelProject(ssh, remoteCwd) {
|
|
5
8
|
const laravelCheck = await ssh.execCommand(
|
|
6
9
|
'if [ -f artisan ] && [ -f composer.json ] && grep -q "laravel/framework" composer.json; then echo "yes"; else echo "no"; fi',
|
|
@@ -99,6 +102,122 @@ async function resolvePhpCommand({
|
|
|
99
102
|
}
|
|
100
103
|
}
|
|
101
104
|
|
|
105
|
+
function createMaintenanceModePlan({
|
|
106
|
+
enabled,
|
|
107
|
+
phpCommand,
|
|
108
|
+
usesPrerender = false,
|
|
109
|
+
renderView = null
|
|
110
|
+
} = {}) {
|
|
111
|
+
const maintenanceUpCommand = `${phpCommand} artisan up`
|
|
112
|
+
|
|
113
|
+
if (!enabled) {
|
|
114
|
+
return {
|
|
115
|
+
enabled: false,
|
|
116
|
+
usesPrerender: false,
|
|
117
|
+
renderView: null,
|
|
118
|
+
downCommand: null,
|
|
119
|
+
upCommand: maintenanceUpCommand
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
enabled: true,
|
|
125
|
+
usesPrerender,
|
|
126
|
+
renderView: usesPrerender ? renderView : null,
|
|
127
|
+
downCommand: usesPrerender && renderView
|
|
128
|
+
? `${phpCommand} artisan down --render="${renderView}"`
|
|
129
|
+
: `${phpCommand} artisan down`,
|
|
130
|
+
upCommand: maintenanceUpCommand
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function resolveMaintenanceMode({
|
|
135
|
+
snapshot,
|
|
136
|
+
remoteIsLaravel,
|
|
137
|
+
runPrompt
|
|
138
|
+
} = {}) {
|
|
139
|
+
if (!remoteIsLaravel) {
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (typeof snapshot?.maintenanceModeEnabled === 'boolean') {
|
|
144
|
+
return snapshot.maintenanceModeEnabled
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (typeof runPrompt !== 'function') {
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const answers = await runPrompt([
|
|
152
|
+
{
|
|
153
|
+
type: 'confirm',
|
|
154
|
+
name: 'enableMaintenanceMode',
|
|
155
|
+
message: 'Enable Laravel maintenance mode for this deployment? (`artisan down` before deploy, `artisan up` after)',
|
|
156
|
+
default: false
|
|
157
|
+
}
|
|
158
|
+
])
|
|
159
|
+
|
|
160
|
+
return Boolean(answers?.enableMaintenanceMode)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function resolveMaintenanceModePlan({
|
|
164
|
+
snapshot,
|
|
165
|
+
remoteIsLaravel,
|
|
166
|
+
remoteCwd,
|
|
167
|
+
maintenanceModeEnabled,
|
|
168
|
+
phpCommand,
|
|
169
|
+
ssh,
|
|
170
|
+
executeRemote,
|
|
171
|
+
logProcessing,
|
|
172
|
+
logWarning
|
|
173
|
+
} = {}) {
|
|
174
|
+
if (!remoteIsLaravel || !maintenanceModeEnabled) {
|
|
175
|
+
return createMaintenanceModePlan({enabled: false, phpCommand})
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (typeof snapshot?.maintenanceModeUsesPrerender === 'boolean') {
|
|
179
|
+
return createMaintenanceModePlan({
|
|
180
|
+
enabled: true,
|
|
181
|
+
phpCommand,
|
|
182
|
+
usesPrerender: snapshot.maintenanceModeUsesPrerender,
|
|
183
|
+
renderView: snapshot.maintenanceModeRenderView ?? PRERENDERED_MAINTENANCE_VIEW
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const capabilityResult = await executeRemote(
|
|
188
|
+
'Inspect Laravel maintenance mode capabilities',
|
|
189
|
+
`${phpCommand} artisan down --help`,
|
|
190
|
+
{printStdout: false, allowFailure: true}
|
|
191
|
+
)
|
|
192
|
+
const helpOutput = `${capabilityResult.stdout ?? ''}\n${capabilityResult.stderr ?? ''}`
|
|
193
|
+
const supportsPrerender = capabilityResult.code === 0 && /(^|\s)--render(?:\[|[=\s]|$)/m.test(helpOutput)
|
|
194
|
+
|
|
195
|
+
if (!supportsPrerender) {
|
|
196
|
+
logProcessing?.('Prerendered Laravel maintenance mode is unavailable on the remote app; using standard maintenance mode.')
|
|
197
|
+
return createMaintenanceModePlan({enabled: true, phpCommand})
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const maintenanceViewCheck = await ssh.execCommand(
|
|
201
|
+
`if [ -f ${PRERENDERED_MAINTENANCE_FILE} ]; then echo "yes"; else echo "no"; fi`,
|
|
202
|
+
{cwd: remoteCwd}
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
if (maintenanceViewCheck.stdout.trim() !== 'yes') {
|
|
206
|
+
logWarning?.(
|
|
207
|
+
`Laravel supports prerendered maintenance mode, but ${PRERENDERED_MAINTENANCE_FILE} is missing; using standard maintenance mode.`
|
|
208
|
+
)
|
|
209
|
+
return createMaintenanceModePlan({enabled: true, phpCommand})
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
logProcessing?.(`Using prerendered Laravel maintenance response (${PRERENDERED_MAINTENANCE_VIEW}).`)
|
|
213
|
+
return createMaintenanceModePlan({
|
|
214
|
+
enabled: true,
|
|
215
|
+
phpCommand,
|
|
216
|
+
usesPrerender: true,
|
|
217
|
+
renderView: PRERENDERED_MAINTENANCE_VIEW
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
102
221
|
export async function buildRemoteDeploymentPlan({
|
|
103
222
|
config,
|
|
104
223
|
snapshot = null,
|
|
@@ -108,7 +227,8 @@ export async function buildRemoteDeploymentPlan({
|
|
|
108
227
|
executeRemote,
|
|
109
228
|
logProcessing,
|
|
110
229
|
logSuccess,
|
|
111
|
-
logWarning
|
|
230
|
+
logWarning,
|
|
231
|
+
runPrompt
|
|
112
232
|
} = {}) {
|
|
113
233
|
const remoteIsLaravel = await detectRemoteLaravelProject(ssh, remoteCwd)
|
|
114
234
|
|
|
@@ -141,12 +261,33 @@ export async function buildRemoteDeploymentPlan({
|
|
|
141
261
|
logWarning
|
|
142
262
|
})
|
|
143
263
|
|
|
264
|
+
const maintenanceModeEnabled = await resolveMaintenanceMode({
|
|
265
|
+
snapshot,
|
|
266
|
+
remoteIsLaravel,
|
|
267
|
+
runPrompt
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
const maintenanceModePlan = await resolveMaintenanceModePlan({
|
|
271
|
+
snapshot,
|
|
272
|
+
remoteIsLaravel,
|
|
273
|
+
remoteCwd,
|
|
274
|
+
maintenanceModeEnabled,
|
|
275
|
+
phpCommand,
|
|
276
|
+
ssh,
|
|
277
|
+
executeRemote,
|
|
278
|
+
logProcessing,
|
|
279
|
+
logWarning
|
|
280
|
+
})
|
|
281
|
+
|
|
144
282
|
const steps = planLaravelDeploymentTasks({
|
|
145
283
|
branch: config.branch,
|
|
146
284
|
isLaravel: remoteIsLaravel,
|
|
147
285
|
changedFiles,
|
|
148
286
|
horizonConfigured,
|
|
149
|
-
phpCommand
|
|
287
|
+
phpCommand,
|
|
288
|
+
maintenanceMode: maintenanceModePlan.enabled,
|
|
289
|
+
maintenanceDownCommand: maintenanceModePlan.downCommand,
|
|
290
|
+
maintenanceUpCommand: maintenanceModePlan.upCommand
|
|
150
291
|
})
|
|
151
292
|
|
|
152
293
|
const usefulSteps = steps.length > 1
|
|
@@ -158,6 +299,9 @@ export async function buildRemoteDeploymentPlan({
|
|
|
158
299
|
projectPath: config.projectPath,
|
|
159
300
|
sshUser: config.sshUser,
|
|
160
301
|
createdAt: new Date().toISOString(),
|
|
302
|
+
maintenanceModeEnabled: maintenanceModePlan.enabled,
|
|
303
|
+
maintenanceModeUsesPrerender: maintenanceModePlan.usesPrerender,
|
|
304
|
+
maintenanceModeRenderView: maintenanceModePlan.renderView,
|
|
161
305
|
changedFiles,
|
|
162
306
|
taskLabels: steps.map((step) => step.label)
|
|
163
307
|
}
|
|
@@ -167,8 +311,13 @@ export async function buildRemoteDeploymentPlan({
|
|
|
167
311
|
changedFiles,
|
|
168
312
|
horizonConfigured,
|
|
169
313
|
phpCommand,
|
|
314
|
+
maintenanceModeEnabled: maintenanceModePlan.enabled,
|
|
315
|
+
maintenanceModeUsesPrerender: maintenanceModePlan.usesPrerender,
|
|
316
|
+
maintenanceModeRenderView: maintenanceModePlan.renderView,
|
|
317
|
+
maintenanceDownCommand: maintenanceModePlan.downCommand,
|
|
318
|
+
maintenanceUpCommand: maintenanceModePlan.upCommand,
|
|
170
319
|
steps,
|
|
171
320
|
usefulSteps,
|
|
172
321
|
pendingSnapshot
|
|
173
322
|
}
|
|
174
|
-
}
|
|
323
|
+
}
|
|
@@ -13,17 +13,30 @@ async function persistPendingSnapshot(rootDir, pendingSnapshot, executeRemote) {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
function logScheduledTasks(steps, {logProcessing} = {}) {
|
|
16
|
-
|
|
16
|
+
const extraTasks = steps
|
|
17
|
+
.filter((step) => !step.command.startsWith('git pull '))
|
|
18
|
+
.map((step) => ` - ${step.label}`)
|
|
19
|
+
|
|
20
|
+
if (extraTasks.length === 0) {
|
|
17
21
|
logProcessing?.('No additional maintenance tasks scheduled beyond git pull.')
|
|
18
22
|
return
|
|
19
23
|
}
|
|
20
24
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.map((step) => ` - ${step.label}`)
|
|
24
|
-
.join('\n')
|
|
25
|
+
logProcessing?.(`Additional tasks scheduled:\n${extraTasks.join('\n')}`)
|
|
26
|
+
}
|
|
25
27
|
|
|
26
|
-
|
|
28
|
+
function trackExecutionState(step, executionState) {
|
|
29
|
+
if (!executionState || !step?.kind) {
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (step.kind === 'maintenance-down') {
|
|
34
|
+
executionState.enteredMaintenanceMode = true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (step.kind === 'maintenance-up') {
|
|
38
|
+
executionState.exitedMaintenanceMode = true
|
|
39
|
+
}
|
|
27
40
|
}
|
|
28
41
|
|
|
29
42
|
export async function executeRemoteDeploymentPlan({
|
|
@@ -32,7 +45,8 @@ export async function executeRemoteDeploymentPlan({
|
|
|
32
45
|
steps,
|
|
33
46
|
usefulSteps,
|
|
34
47
|
pendingSnapshot = null,
|
|
35
|
-
logProcessing
|
|
48
|
+
logProcessing,
|
|
49
|
+
executionState = null
|
|
36
50
|
} = {}) {
|
|
37
51
|
if (usefulSteps && pendingSnapshot) {
|
|
38
52
|
await persistPendingSnapshot(rootDir, pendingSnapshot, executeRemote)
|
|
@@ -45,6 +59,7 @@ export async function executeRemoteDeploymentPlan({
|
|
|
45
59
|
try {
|
|
46
60
|
for (const step of steps) {
|
|
47
61
|
await executeRemote(step.label, step.command)
|
|
62
|
+
trackExecutionState(step, executionState)
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
completed = true
|
|
@@ -58,4 +73,4 @@ export async function executeRemoteDeploymentPlan({
|
|
|
58
73
|
await clearPendingTasksSnapshot(rootDir)
|
|
59
74
|
}
|
|
60
75
|
}
|
|
61
|
-
}
|
|
76
|
+
}
|
|
@@ -3,7 +3,10 @@ export function planLaravelDeploymentTasks({
|
|
|
3
3
|
isLaravel,
|
|
4
4
|
changedFiles,
|
|
5
5
|
horizonConfigured = false,
|
|
6
|
-
phpCommand = 'php'
|
|
6
|
+
phpCommand = 'php',
|
|
7
|
+
maintenanceMode = false,
|
|
8
|
+
maintenanceDownCommand = null,
|
|
9
|
+
maintenanceUpCommand = null
|
|
7
10
|
}) {
|
|
8
11
|
const safeChangedFiles = Array.isArray(changedFiles) ? changedFiles : []
|
|
9
12
|
|
|
@@ -43,12 +46,20 @@ export function planLaravelDeploymentTasks({
|
|
|
43
46
|
const shouldClearCaches = hasPhpChanges
|
|
44
47
|
const shouldRestartQueues = hasPhpChanges
|
|
45
48
|
|
|
46
|
-
const steps = [
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
const steps = []
|
|
50
|
+
|
|
51
|
+
if (maintenanceMode && isLaravel) {
|
|
52
|
+
steps.push({
|
|
53
|
+
label: 'Enable Laravel maintenance mode',
|
|
54
|
+
command: maintenanceDownCommand ?? `${phpCommand} artisan down`,
|
|
55
|
+
kind: 'maintenance-down'
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
steps.push({
|
|
60
|
+
label: `Pull latest changes for ${branch}`,
|
|
61
|
+
command: `git pull origin ${branch}`
|
|
62
|
+
})
|
|
52
63
|
|
|
53
64
|
if (shouldRunComposer) {
|
|
54
65
|
// Composer is a PHP script, so we need to run it with the correct PHP version
|
|
@@ -96,5 +107,13 @@ export function planLaravelDeploymentTasks({
|
|
|
96
107
|
})
|
|
97
108
|
}
|
|
98
109
|
|
|
110
|
+
if (maintenanceMode && isLaravel) {
|
|
111
|
+
steps.push({
|
|
112
|
+
label: 'Disable Laravel maintenance mode',
|
|
113
|
+
command: maintenanceUpCommand ?? `${phpCommand} artisan up`,
|
|
114
|
+
kind: 'maintenance-up'
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
99
118
|
return steps
|
|
100
|
-
}
|
|
119
|
+
}
|
|
@@ -3,7 +3,7 @@ import process from 'node:process'
|
|
|
3
3
|
import {ensureLocalRepositoryState} from '../../deploy/local-repo.mjs'
|
|
4
4
|
import {bumpLocalPackageVersion} from './bump-local-package-version.mjs'
|
|
5
5
|
import {resolveLocalDeploymentContext} from './resolve-local-deployment-context.mjs'
|
|
6
|
-
import {runLocalDeploymentChecks} from './run-local-deployment-checks.mjs'
|
|
6
|
+
import {resolveLocalDeploymentCheckSupport, runLocalDeploymentChecks} from './run-local-deployment-checks.mjs'
|
|
7
7
|
|
|
8
8
|
export async function prepareLocalDeployment(config, {
|
|
9
9
|
snapshot = null,
|
|
@@ -17,6 +17,11 @@ export async function prepareLocalDeployment(config, {
|
|
|
17
17
|
logWarning
|
|
18
18
|
} = {}) {
|
|
19
19
|
const context = await resolveLocalDeploymentContext(rootDir)
|
|
20
|
+
const checkSupport = await resolveLocalDeploymentCheckSupport({
|
|
21
|
+
rootDir,
|
|
22
|
+
isLaravel: context.isLaravel,
|
|
23
|
+
runCommandCapture
|
|
24
|
+
})
|
|
20
25
|
|
|
21
26
|
if (!snapshot && context.isLaravel) {
|
|
22
27
|
await bumpLocalPackageVersion(rootDir, {
|
|
@@ -45,7 +50,9 @@ export async function prepareLocalDeployment(config, {
|
|
|
45
50
|
runCommandCapture,
|
|
46
51
|
logProcessing,
|
|
47
52
|
logSuccess,
|
|
48
|
-
logWarning
|
|
53
|
+
logWarning,
|
|
54
|
+
lintCommand: checkSupport.lintCommand,
|
|
55
|
+
testCommand: checkSupport.testCommand
|
|
49
56
|
})
|
|
50
57
|
|
|
51
58
|
return context
|
|
@@ -21,6 +21,52 @@ async function resolveRemoteHome(ssh, sshUser) {
|
|
|
21
21
|
return remoteHomeResult.stdout.trim() || `/home/${sshUser}`
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
async function maybeRecoverLaravelMaintenanceMode({
|
|
25
|
+
remotePlan,
|
|
26
|
+
executionState,
|
|
27
|
+
executeRemote,
|
|
28
|
+
runPrompt,
|
|
29
|
+
logWarning
|
|
30
|
+
} = {}) {
|
|
31
|
+
if (!remotePlan?.remoteIsLaravel || !remotePlan?.maintenanceModeEnabled) {
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!executionState?.enteredMaintenanceMode || executionState.exitedMaintenanceMode) {
|
|
36
|
+
return
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (typeof runPrompt !== 'function' || typeof executeRemote !== 'function') {
|
|
40
|
+
logWarning?.('Deployment failed while Laravel maintenance mode may still be enabled.')
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
try {
|
|
45
|
+
const answers = await runPrompt([
|
|
46
|
+
{
|
|
47
|
+
type: 'confirm',
|
|
48
|
+
name: 'disableMaintenanceMode',
|
|
49
|
+
message: 'Deployment failed after Laravel maintenance mode was enabled. Run `artisan up` now?',
|
|
50
|
+
default: true
|
|
51
|
+
}
|
|
52
|
+
])
|
|
53
|
+
const disableMaintenanceMode = answers?.disableMaintenanceMode === true
|
|
54
|
+
|
|
55
|
+
if (!disableMaintenanceMode) {
|
|
56
|
+
logWarning?.('Laravel maintenance mode remains enabled because recovery was not confirmed.')
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await executeRemote(
|
|
61
|
+
'Disable Laravel maintenance mode',
|
|
62
|
+
remotePlan.maintenanceUpCommand ?? `${remotePlan.phpCommand} artisan up`
|
|
63
|
+
)
|
|
64
|
+
executionState.exitedMaintenanceMode = true
|
|
65
|
+
} catch (error) {
|
|
66
|
+
logWarning?.(`Failed to disable Laravel maintenance mode after deployment error: ${error.message}`)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
24
70
|
export async function runDeployment(config, options = {}) {
|
|
25
71
|
const {
|
|
26
72
|
snapshot = null,
|
|
@@ -58,6 +104,12 @@ export async function runDeployment(config, options = {}) {
|
|
|
58
104
|
const privateKeyPath = await resolveSshKeyPath(config.sshKey)
|
|
59
105
|
const privateKey = await fs.readFile(privateKeyPath, 'utf8')
|
|
60
106
|
let remoteCwd = null
|
|
107
|
+
let executeRemote = null
|
|
108
|
+
let remotePlan = null
|
|
109
|
+
const executionState = {
|
|
110
|
+
enteredMaintenanceMode: false,
|
|
111
|
+
exitedMaintenanceMode: false
|
|
112
|
+
}
|
|
61
113
|
|
|
62
114
|
logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
|
|
63
115
|
|
|
@@ -78,7 +130,7 @@ export async function runDeployment(config, options = {}) {
|
|
|
78
130
|
lockAcquired = true
|
|
79
131
|
logProcessing(`Lock acquired. Running deployment commands in ${remoteCwd}...`)
|
|
80
132
|
|
|
81
|
-
|
|
133
|
+
executeRemote = createRemoteExecutor({
|
|
82
134
|
ssh,
|
|
83
135
|
rootDir,
|
|
84
136
|
remoteCwd,
|
|
@@ -88,7 +140,7 @@ export async function runDeployment(config, options = {}) {
|
|
|
88
140
|
logError
|
|
89
141
|
})
|
|
90
142
|
|
|
91
|
-
|
|
143
|
+
remotePlan = await buildRemoteDeploymentPlan({
|
|
92
144
|
config,
|
|
93
145
|
snapshot,
|
|
94
146
|
rootDir,
|
|
@@ -96,6 +148,7 @@ export async function runDeployment(config, options = {}) {
|
|
|
96
148
|
ssh,
|
|
97
149
|
remoteCwd,
|
|
98
150
|
executeRemote,
|
|
151
|
+
runPrompt,
|
|
99
152
|
logProcessing,
|
|
100
153
|
logSuccess,
|
|
101
154
|
logWarning
|
|
@@ -107,7 +160,8 @@ export async function runDeployment(config, options = {}) {
|
|
|
107
160
|
steps: remotePlan.steps,
|
|
108
161
|
usefulSteps: remotePlan.usefulSteps,
|
|
109
162
|
pendingSnapshot: remotePlan.pendingSnapshot,
|
|
110
|
-
logProcessing
|
|
163
|
+
logProcessing,
|
|
164
|
+
executionState
|
|
111
165
|
})
|
|
112
166
|
|
|
113
167
|
logSuccess('\nDeployment commands completed successfully.')
|
|
@@ -120,6 +174,14 @@ export async function runDeployment(config, options = {}) {
|
|
|
120
174
|
logError(`\nTask output has been logged to: ${logPath}`)
|
|
121
175
|
}
|
|
122
176
|
|
|
177
|
+
await maybeRecoverLaravelMaintenanceMode({
|
|
178
|
+
remotePlan,
|
|
179
|
+
executionState,
|
|
180
|
+
executeRemote,
|
|
181
|
+
runPrompt,
|
|
182
|
+
logWarning
|
|
183
|
+
})
|
|
184
|
+
|
|
123
185
|
if (lockAcquired && ssh && remoteCwd) {
|
|
124
186
|
try {
|
|
125
187
|
await compareLocksAndPrompt(rootDir, ssh, remoteCwd, {runPrompt, logWarning})
|
|
@@ -144,4 +206,4 @@ export async function runDeployment(config, options = {}) {
|
|
|
144
206
|
ssh.dispose()
|
|
145
207
|
}
|
|
146
208
|
}
|
|
147
|
-
}
|
|
209
|
+
}
|
|
@@ -12,20 +12,62 @@ async function hasUncommittedChanges(rootDir, {runCommandCapture} = {}) {
|
|
|
12
12
|
})
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
function supportsArtisanTestCommand(listOutput = '') {
|
|
16
|
+
return /(?:^|\n)\s*test(?:\s|$)/m.test(listOutput)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function resolveSupportedLaravelTestCommand(rootDir, {runCommandCapture} = {}) {
|
|
16
20
|
if (!commandExists('php')) {
|
|
17
|
-
|
|
18
|
-
'PHP is not available in PATH
|
|
19
|
-
'
|
|
20
|
-
|
|
21
|
+
throw new Error(
|
|
22
|
+
'Release cannot run because PHP is not available in PATH.\n' +
|
|
23
|
+
'Zephyr requires `php artisan test --compact` for Laravel projects before deployment.'
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let artisanCommands
|
|
28
|
+
try {
|
|
29
|
+
artisanCommands = await runCommandCapture('php', ['artisan', 'list'], {cwd: rootDir})
|
|
30
|
+
} catch (error) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
'Release cannot run because Zephyr could not verify support for `php artisan test`.\n' +
|
|
33
|
+
`Ensure the project can run \`php artisan list\` locally before deployment.\n${error.message}`
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!supportsArtisanTestCommand(artisanCommands)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
'Release cannot run because this Laravel project does not support `php artisan test`.\n' +
|
|
40
|
+
'Zephyr requires Laravel\'s built-in test command before deployment. PHPUnit-only test setups are not supported.'
|
|
21
41
|
)
|
|
22
|
-
return
|
|
23
42
|
}
|
|
24
43
|
|
|
44
|
+
return {
|
|
45
|
+
command: 'php',
|
|
46
|
+
args: ['artisan', 'test', '--compact']
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function resolveLocalDeploymentCheckSupport({
|
|
51
|
+
rootDir,
|
|
52
|
+
isLaravel,
|
|
53
|
+
runCommandCapture
|
|
54
|
+
} = {}) {
|
|
55
|
+
const lintCommand = await preflight.resolveSupportedLintCommand(rootDir, {commandExists})
|
|
56
|
+
const testCommand = isLaravel
|
|
57
|
+
? await resolveSupportedLaravelTestCommand(rootDir, {runCommandCapture})
|
|
58
|
+
: null
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
lintCommand,
|
|
62
|
+
testCommand
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runLocalLaravelTests(rootDir, {runCommand, logProcessing, logSuccess, testCommand} = {}) {
|
|
25
67
|
logProcessing?.('Running Laravel tests locally...')
|
|
26
68
|
|
|
27
69
|
try {
|
|
28
|
-
await runCommand(
|
|
70
|
+
await runCommand(testCommand.command, testCommand.args, {cwd: rootDir})
|
|
29
71
|
logSuccess?.('Local tests passed.')
|
|
30
72
|
} catch (error) {
|
|
31
73
|
if (error.code === 'ENOENT') {
|
|
@@ -47,10 +89,22 @@ export async function runLocalDeploymentChecks({
|
|
|
47
89
|
runCommandCapture,
|
|
48
90
|
logProcessing,
|
|
49
91
|
logSuccess,
|
|
50
|
-
logWarning
|
|
92
|
+
logWarning,
|
|
93
|
+
lintCommand = undefined,
|
|
94
|
+
testCommand = undefined
|
|
51
95
|
} = {}) {
|
|
96
|
+
const support = lintCommand !== undefined || testCommand !== undefined
|
|
97
|
+
? {lintCommand, testCommand}
|
|
98
|
+
: await resolveLocalDeploymentCheckSupport({
|
|
99
|
+
rootDir,
|
|
100
|
+
isLaravel,
|
|
101
|
+
runCommandCapture
|
|
102
|
+
})
|
|
103
|
+
|
|
52
104
|
if (hasHook) {
|
|
53
|
-
logProcessing?.(
|
|
105
|
+
logProcessing?.(
|
|
106
|
+
'Pre-push git hook detected. Built-in release checks are supported, but Zephyr will skip executing them here. If Zephyr pushes local commits during this release, the hook will run during git push.'
|
|
107
|
+
)
|
|
54
108
|
return
|
|
55
109
|
}
|
|
56
110
|
|
|
@@ -59,7 +113,8 @@ export async function runLocalDeploymentChecks({
|
|
|
59
113
|
logProcessing,
|
|
60
114
|
logSuccess,
|
|
61
115
|
logWarning,
|
|
62
|
-
commandExists
|
|
116
|
+
commandExists,
|
|
117
|
+
lintCommand: support.lintCommand
|
|
63
118
|
})
|
|
64
119
|
|
|
65
120
|
if (lintRan) {
|
|
@@ -75,6 +130,11 @@ export async function runLocalDeploymentChecks({
|
|
|
75
130
|
}
|
|
76
131
|
|
|
77
132
|
if (isLaravel) {
|
|
78
|
-
await runLocalLaravelTests(rootDir, {
|
|
133
|
+
await runLocalLaravelTests(rootDir, {
|
|
134
|
+
runCommand,
|
|
135
|
+
logProcessing,
|
|
136
|
+
logSuccess,
|
|
137
|
+
testCommand: support.testCommand
|
|
138
|
+
})
|
|
79
139
|
}
|
|
80
140
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { getCurrentBranch as getCurrentBranchImpl, getUpstreamRef as getUpstreamRefImpl } from '../utils/git.mjs'
|
|
2
|
+
import {hasPrePushHook} from './preflight.mjs'
|
|
2
3
|
|
|
3
4
|
export async function getCurrentBranch(rootDir) {
|
|
4
5
|
const branch = await getCurrentBranchImpl(rootDir)
|
|
@@ -179,7 +180,22 @@ async function commitAndPushStagedChanges(targetBranch, rootDir, {
|
|
|
179
180
|
|
|
180
181
|
logProcessing?.('Committing staged changes before deployment...')
|
|
181
182
|
await runCommand('git', ['commit', '-m', message], { cwd: rootDir })
|
|
182
|
-
|
|
183
|
+
|
|
184
|
+
const prePushHookPresent = await hasPrePushHook(rootDir)
|
|
185
|
+
if (prePushHookPresent) {
|
|
186
|
+
logProcessing?.('Pre-push git hook detected. Running hook during git push...')
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
await runCommand('git', ['push', 'origin', targetBranch], { cwd: rootDir })
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (prePushHookPresent) {
|
|
193
|
+
throw new Error(`Git push failed while the pre-push hook was running. See hook output above.\n${error.message}`)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
throw error
|
|
197
|
+
}
|
|
198
|
+
|
|
183
199
|
logSuccess?.(`Committed and pushed changes to origin/${targetBranch}.`)
|
|
184
200
|
|
|
185
201
|
const finalStatus = await getGitStatus(rootDir)
|
|
@@ -195,14 +211,17 @@ export async function ensureCommittedChangesPushed(targetBranch, rootDir, {
|
|
|
195
211
|
logProcessing,
|
|
196
212
|
logSuccess,
|
|
197
213
|
logWarning,
|
|
198
|
-
getUpstreamRef: getUpstreamRefFn = getUpstreamRef
|
|
214
|
+
getUpstreamRef: getUpstreamRefFn = getUpstreamRef,
|
|
215
|
+
readUpstreamSyncState: readUpstreamSyncStateFn = (branch, dir) =>
|
|
216
|
+
readUpstreamSyncState(branch, dir, {
|
|
217
|
+
runCommand,
|
|
218
|
+
runCommandCapture,
|
|
219
|
+
logWarning,
|
|
220
|
+
getUpstreamRef: getUpstreamRefFn
|
|
221
|
+
}),
|
|
222
|
+
hasPrePushHook: hasPrePushHookFn = hasPrePushHook
|
|
199
223
|
} = {}) {
|
|
200
|
-
const syncState = await
|
|
201
|
-
runCommand,
|
|
202
|
-
runCommandCapture,
|
|
203
|
-
logWarning,
|
|
204
|
-
getUpstreamRef: getUpstreamRefFn
|
|
205
|
-
})
|
|
224
|
+
const syncState = await readUpstreamSyncStateFn(targetBranch, rootDir)
|
|
206
225
|
|
|
207
226
|
const {
|
|
208
227
|
upstreamRef,
|
|
@@ -234,8 +253,31 @@ export async function ensureCommittedChangesPushed(targetBranch, rootDir, {
|
|
|
234
253
|
|
|
235
254
|
const commitLabel = aheadCount === 1 ? 'commit' : 'commits'
|
|
236
255
|
logProcessing?.(`Found ${aheadCount} ${commitLabel} not yet pushed to ${upstreamRef}. Pushing before deployment...`)
|
|
256
|
+
const prePushHookPresent = await hasPrePushHookFn(rootDir)
|
|
257
|
+
|
|
258
|
+
if (prePushHookPresent) {
|
|
259
|
+
logProcessing?.('Pre-push git hook detected. Running hook during git push...')
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
await runCommandCapture('git', ['push', remoteName, `${targetBranch}:${upstreamBranch}`], { cwd: rootDir })
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (prePushHookPresent) {
|
|
266
|
+
const hookOutput = [error.stdout, error.stderr]
|
|
267
|
+
.map((value) => (typeof value === 'string' ? value.trim() : ''))
|
|
268
|
+
.filter(Boolean)
|
|
269
|
+
.join('\n')
|
|
270
|
+
|
|
271
|
+
throw new Error(
|
|
272
|
+
hookOutput
|
|
273
|
+
? `Git push failed while the pre-push hook was running.\n${hookOutput}`
|
|
274
|
+
: `Git push failed while the pre-push hook was running.\n${error.message}`
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
throw error
|
|
279
|
+
}
|
|
237
280
|
|
|
238
|
-
await runCommandCapture('git', ['push', remoteName, `${targetBranch}:${upstreamBranch}`], { cwd: rootDir })
|
|
239
281
|
logSuccess?.(`Pushed committed changes to ${upstreamRef}.`)
|
|
240
282
|
|
|
241
283
|
return { pushed: true, upstreamRef }
|
package/src/deploy/preflight.mjs
CHANGED
|
@@ -45,29 +45,67 @@ export async function hasLaravelPint(rootDir) {
|
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
export async function
|
|
48
|
+
export async function resolveSupportedLintCommand(rootDir, {commandExists} = {}) {
|
|
49
49
|
const hasNpmLint = await hasLintScript(rootDir)
|
|
50
50
|
const hasPint = await hasLaravelPint(rootDir)
|
|
51
51
|
|
|
52
52
|
if (hasNpmLint) {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
53
|
+
if (commandExists && !commandExists('npm')) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
'Release cannot run because `npm run lint` is configured but npm is not available in PATH.\n' +
|
|
56
|
+
'Install npm or fix your PATH before releasing.'
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
type: 'npm',
|
|
62
|
+
command: 'npm',
|
|
63
|
+
args: ['run', 'lint'],
|
|
64
|
+
label: 'npm lint'
|
|
65
|
+
}
|
|
57
66
|
}
|
|
58
67
|
|
|
59
68
|
if (hasPint) {
|
|
60
|
-
// Check if PHP is available before trying to run Pint
|
|
61
69
|
if (commandExists && !commandExists('php')) {
|
|
62
|
-
|
|
63
|
-
'PHP is not available in PATH
|
|
64
|
-
'
|
|
70
|
+
throw new Error(
|
|
71
|
+
'Release cannot run because Laravel Pint is present but PHP is not available in PATH.\n' +
|
|
72
|
+
'Zephyr requires `php vendor/bin/pint` to run successfully before deployment.'
|
|
65
73
|
)
|
|
66
|
-
return false
|
|
67
74
|
}
|
|
68
75
|
|
|
76
|
+
return {
|
|
77
|
+
type: 'pint',
|
|
78
|
+
command: 'php',
|
|
79
|
+
args: ['vendor/bin/pint'],
|
|
80
|
+
label: 'Laravel Pint'
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
throw new Error(
|
|
85
|
+
'Release cannot run because no supported lint command was found.\n' +
|
|
86
|
+
'Zephyr requires either `npm run lint` or Laravel Pint (`vendor/bin/pint`) before deployment.'
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function runLinting(rootDir, {
|
|
91
|
+
runCommand,
|
|
92
|
+
logProcessing,
|
|
93
|
+
logSuccess,
|
|
94
|
+
commandExists,
|
|
95
|
+
lintCommand = null
|
|
96
|
+
} = {}) {
|
|
97
|
+
const selectedLintCommand = lintCommand ?? await resolveSupportedLintCommand(rootDir, {commandExists})
|
|
98
|
+
|
|
99
|
+
if (selectedLintCommand.type === 'npm') {
|
|
100
|
+
logProcessing?.('Running npm lint...')
|
|
101
|
+
await runCommand(selectedLintCommand.command, selectedLintCommand.args, { cwd: rootDir })
|
|
102
|
+
logSuccess?.('Linting completed.')
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (selectedLintCommand.type === 'pint') {
|
|
69
107
|
logProcessing?.('Running Laravel Pint...')
|
|
70
|
-
await runCommand(
|
|
108
|
+
await runCommand(selectedLintCommand.command, selectedLintCommand.args, { cwd: rootDir })
|
|
71
109
|
logSuccess?.('Linting completed.')
|
|
72
110
|
return true
|
|
73
111
|
}
|
|
@@ -123,4 +161,3 @@ export async function isLocalLaravelProject(rootDir) {
|
|
|
123
161
|
return false
|
|
124
162
|
}
|
|
125
163
|
}
|
|
126
|
-
|
package/src/main.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
|
+
import {createRequire} from 'node:module'
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import process from 'node:process'
|
|
4
5
|
|
|
@@ -16,6 +17,8 @@ import {runDeployment} from './application/deploy/run-deployment.mjs'
|
|
|
16
17
|
|
|
17
18
|
const RELEASE_SCRIPT_NAME = 'release'
|
|
18
19
|
const RELEASE_SCRIPT_COMMAND = 'npx @wyxos/zephyr@latest'
|
|
20
|
+
const require = createRequire(import.meta.url)
|
|
21
|
+
const {version: ZEPHYR_VERSION} = require('../package.json')
|
|
19
22
|
|
|
20
23
|
const appContext = createAppContext()
|
|
21
24
|
const {
|
|
@@ -36,6 +39,8 @@ async function runRemoteTasks(config, options = {}) {
|
|
|
36
39
|
}
|
|
37
40
|
|
|
38
41
|
async function main(releaseType = null, versionArg = null) {
|
|
42
|
+
logProcessing(`Zephyr v${ZEPHYR_VERSION}`)
|
|
43
|
+
|
|
39
44
|
if (releaseType === 'node' || releaseType === 'vue') {
|
|
40
45
|
try {
|
|
41
46
|
await releaseNode()
|