@wyxos/zephyr 0.4.2 → 0.4.4
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 +2 -0
- package/package.json +1 -1
- package/src/application/deploy/build-remote-deployment-plan.mjs +8 -8
- package/src/application/deploy/run-deployment.mjs +2 -0
- package/src/application/deploy/run-local-deployment-checks.mjs +2 -2
- package/src/cli/options.mjs +1 -1
- package/src/deploy/locks.mjs +150 -42
package/README.md
CHANGED
|
@@ -87,6 +87,8 @@ If Zephyr would normally prompt to:
|
|
|
87
87
|
|
|
88
88
|
then non-interactive mode stops immediately with a clear error instead.
|
|
89
89
|
|
|
90
|
+
For Laravel app deployments, `--maintenance on|off` overrides the maintenance prompt when you want an explicit choice instead of an interactive confirm.
|
|
91
|
+
|
|
90
92
|
## AI Agents and Automation
|
|
91
93
|
|
|
92
94
|
Zephyr can be used safely by Codex, CI jobs, or other automation once configuration is already in place.
|
package/package.json
CHANGED
|
@@ -253,17 +253,17 @@ async function resolveMaintenanceMode({
|
|
|
253
253
|
return snapshot.maintenanceModeEnabled
|
|
254
254
|
}
|
|
255
255
|
|
|
256
|
-
if (executionMode
|
|
257
|
-
if (typeof executionMode.maintenanceMode !== 'boolean') {
|
|
258
|
-
throw new ZephyrError(
|
|
259
|
-
'Zephyr cannot run this Laravel deployment non-interactively without an explicit maintenance-mode decision. Pass --maintenance on or --maintenance off.',
|
|
260
|
-
{code: 'ZEPHYR_MAINTENANCE_FLAG_REQUIRED'}
|
|
261
|
-
)
|
|
262
|
-
}
|
|
263
|
-
|
|
256
|
+
if (typeof executionMode.maintenanceMode === 'boolean') {
|
|
264
257
|
return executionMode.maintenanceMode
|
|
265
258
|
}
|
|
266
259
|
|
|
260
|
+
if (executionMode?.interactive === false) {
|
|
261
|
+
throw new ZephyrError(
|
|
262
|
+
'Zephyr cannot run this Laravel deployment non-interactively without an explicit maintenance-mode decision. Pass --maintenance on or --maintenance off.',
|
|
263
|
+
{code: 'ZEPHYR_MAINTENANCE_FLAG_REQUIRED'}
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
267
|
if (typeof runPrompt !== 'function') {
|
|
268
268
|
return false
|
|
269
269
|
}
|
|
@@ -201,6 +201,7 @@ export async function runDeployment(config, options = {}) {
|
|
|
201
201
|
logProcessing('Connection established. Acquiring deployment lock on server...')
|
|
202
202
|
await acquireRemoteLock(ssh, remoteCwd, rootDir, {
|
|
203
203
|
runPrompt,
|
|
204
|
+
logProcessing,
|
|
204
205
|
logWarning,
|
|
205
206
|
interactive: executionMode?.interactive !== false
|
|
206
207
|
})
|
|
@@ -268,6 +269,7 @@ export async function runDeployment(config, options = {}) {
|
|
|
268
269
|
try {
|
|
269
270
|
await compareLocksAndPrompt(rootDir, ssh, remoteCwd, {
|
|
270
271
|
runPrompt,
|
|
272
|
+
logProcessing,
|
|
271
273
|
logWarning,
|
|
272
274
|
interactive: executionMode?.interactive !== false
|
|
273
275
|
})
|
|
@@ -105,14 +105,14 @@ export async function runLocalDeploymentChecks({
|
|
|
105
105
|
if (hasHook) {
|
|
106
106
|
if (skipGitHooks) {
|
|
107
107
|
logWarning?.(
|
|
108
|
-
'Pre-push git hook detected.
|
|
108
|
+
'Pre-push git hook detected. Zephyr will run its built-in release checks manually because --skip-git-hooks is enabled, and the hook will be bypassed during git push.'
|
|
109
109
|
)
|
|
110
110
|
} else {
|
|
111
111
|
logProcessing?.(
|
|
112
112
|
'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.'
|
|
113
113
|
)
|
|
114
|
+
return
|
|
114
115
|
}
|
|
115
|
-
return
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
const lintRan = await preflight.runLinting(rootDir, {
|
package/src/cli/options.mjs
CHANGED
|
@@ -34,7 +34,7 @@ export function parseCliOptions(args = process.argv.slice(2)) {
|
|
|
34
34
|
.option('--preset <name>', 'Preset name to use for non-interactive app deployments.')
|
|
35
35
|
.option('--resume-pending', 'Resume a saved pending deployment snapshot without prompting.')
|
|
36
36
|
.option('--discard-pending', 'Discard a saved pending deployment snapshot without prompting.')
|
|
37
|
-
.option('--maintenance <mode>', 'Laravel maintenance mode policy for
|
|
37
|
+
.option('--maintenance <mode>', 'Laravel maintenance mode policy for app deployments (on|off).')
|
|
38
38
|
.option('--skip-git-hooks', 'Bypass local git hooks for any commits and pushes Zephyr performs.')
|
|
39
39
|
.option('--skip-tests', 'Skip test execution in package release workflows.')
|
|
40
40
|
.option('--skip-lint', 'Skip lint execution in package release workflows.')
|
package/src/deploy/locks.mjs
CHANGED
|
@@ -14,6 +14,14 @@ function createLockPayload() {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
function createLockKey(details = {}) {
|
|
18
|
+
return `${details.user}@${details.hostname}:${details.pid}:${details.startedAt}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function waitForDelay(delayMs) {
|
|
22
|
+
return new Promise((resolve) => setTimeout(resolve, delayMs))
|
|
23
|
+
}
|
|
24
|
+
|
|
17
25
|
export async function acquireLocalLock(rootDir) {
|
|
18
26
|
const lockPath = getLockFilePath(rootDir)
|
|
19
27
|
const configDir = getProjectConfigDir(rootDir)
|
|
@@ -68,12 +76,12 @@ export async function readRemoteLock(ssh, remoteCwd) {
|
|
|
68
76
|
return null
|
|
69
77
|
}
|
|
70
78
|
|
|
71
|
-
function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
}
|
|
79
|
+
async function removeRemoteLock(ssh, remoteCwd) {
|
|
80
|
+
const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
|
|
81
|
+
const escapedLockPath = lockPath.replace(/'/g, "'\\''")
|
|
82
|
+
const removeCommand = `rm -f '${escapedLockPath}'`
|
|
83
|
+
|
|
84
|
+
await ssh.execCommand(removeCommand, { cwd: remoteCwd })
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
function formatLockHolder(details = {}) {
|
|
@@ -83,10 +91,80 @@ function formatLockHolder(details = {}) {
|
|
|
83
91
|
return { startedBy, startedAt }
|
|
84
92
|
}
|
|
85
93
|
|
|
94
|
+
function buildRemoteLockConflictMessage(lockDetails, { stale = false } = {}) {
|
|
95
|
+
const { startedBy, startedAt } = formatLockHolder(lockDetails)
|
|
96
|
+
|
|
97
|
+
return stale
|
|
98
|
+
? `Stale deployment lock detected on the server (started by ${startedBy}${startedAt}).`
|
|
99
|
+
: `Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}).`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function promptToResolveRemoteLockConflict(rootDir, ssh, remoteCwd, lockDetails, {
|
|
103
|
+
runPrompt,
|
|
104
|
+
logWarning,
|
|
105
|
+
logProcessing,
|
|
106
|
+
interactive = true,
|
|
107
|
+
stale = false,
|
|
108
|
+
wait = waitForDelay
|
|
109
|
+
} = {}) {
|
|
110
|
+
const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
|
|
111
|
+
|
|
112
|
+
if (!interactive) {
|
|
113
|
+
if (stale) {
|
|
114
|
+
throw new ZephyrError(
|
|
115
|
+
`Stale deployment lock detected on the server (started by ${formatLockHolder(lockDetails).startedBy}${formatLockHolder(lockDetails).startedAt}). Remove ${remoteCwd}/${lockPath} manually before rerunning with --non-interactive.`,
|
|
116
|
+
{code: 'ZEPHYR_STALE_REMOTE_LOCK'}
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { startedBy, startedAt } = formatLockHolder(lockDetails)
|
|
121
|
+
throw new Error(
|
|
122
|
+
`Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/${lockPath} if you are sure it is stale.`
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (typeof runPrompt !== 'function') {
|
|
127
|
+
throw new Error('Remote lock conflicts require runPrompt when Zephyr is interactive.')
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
let currentLock = lockDetails
|
|
131
|
+
|
|
132
|
+
while (currentLock) {
|
|
133
|
+
const { action } = await runPrompt([
|
|
134
|
+
{
|
|
135
|
+
type: 'list',
|
|
136
|
+
name: 'action',
|
|
137
|
+
message: `${buildRemoteLockConflictMessage(currentLock, { stale })} What would you like to do?`,
|
|
138
|
+
choices: [
|
|
139
|
+
{name: 'Delete the lock file and continue', value: 'delete'},
|
|
140
|
+
{name: 'Wait 60 seconds and check again', value: 'wait'}
|
|
141
|
+
],
|
|
142
|
+
default: 'wait'
|
|
143
|
+
}
|
|
144
|
+
])
|
|
145
|
+
|
|
146
|
+
if (action === 'delete') {
|
|
147
|
+
await removeRemoteLock(ssh, remoteCwd)
|
|
148
|
+
await releaseLocalLock(rootDir, { logWarning })
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
logProcessing?.('Waiting 60 seconds before checking the remote deployment lock again...')
|
|
153
|
+
await wait(60_000)
|
|
154
|
+
|
|
155
|
+
currentLock = await readRemoteLock(ssh, remoteCwd)
|
|
156
|
+
if (!currentLock) {
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
86
162
|
export async function compareLocksAndPrompt(rootDir, ssh, remoteCwd, {
|
|
87
163
|
runPrompt,
|
|
88
164
|
logWarning,
|
|
89
|
-
|
|
165
|
+
logProcessing,
|
|
166
|
+
interactive = true,
|
|
167
|
+
wait = waitForDelay
|
|
90
168
|
} = {}) {
|
|
91
169
|
const localLock = await readLocalLock(rootDir)
|
|
92
170
|
const remoteLock = await readRemoteLock(ssh, remoteCwd)
|
|
@@ -95,36 +173,27 @@ export async function compareLocksAndPrompt(rootDir, ssh, remoteCwd, {
|
|
|
95
173
|
return false
|
|
96
174
|
}
|
|
97
175
|
|
|
98
|
-
const localKey =
|
|
99
|
-
const remoteKey =
|
|
176
|
+
const localKey = createLockKey(localLock)
|
|
177
|
+
const remoteKey = createLockKey(remoteLock)
|
|
100
178
|
|
|
101
179
|
if (localKey === remoteKey) {
|
|
102
|
-
const { startedBy, startedAt } = formatLockHolder(remoteLock)
|
|
103
|
-
|
|
104
180
|
if (!interactive) {
|
|
105
181
|
throw new ZephyrError(
|
|
106
|
-
`Stale deployment lock detected on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/.zephyr/${PROJECT_LOCK_FILE} manually before rerunning with --non-interactive.`,
|
|
182
|
+
`Stale deployment lock detected on the server (started by ${formatLockHolder(remoteLock).startedBy}${formatLockHolder(remoteLock).startedAt}). Remove ${remoteCwd}/.zephyr/${PROJECT_LOCK_FILE} manually before rerunning with --non-interactive.`,
|
|
107
183
|
{code: 'ZEPHYR_STALE_REMOTE_LOCK'}
|
|
108
184
|
)
|
|
109
185
|
}
|
|
110
186
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
187
|
+
await promptToResolveRemoteLockConflict(rootDir, ssh, remoteCwd, remoteLock, {
|
|
188
|
+
runPrompt,
|
|
189
|
+
logWarning,
|
|
190
|
+
logProcessing,
|
|
191
|
+
interactive,
|
|
192
|
+
stale: true,
|
|
193
|
+
wait
|
|
194
|
+
})
|
|
119
195
|
|
|
120
|
-
|
|
121
|
-
const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
|
|
122
|
-
const escapedLockPath = lockPath.replace(/'/g, "'\\''")
|
|
123
|
-
const removeCommand = `rm -f '${escapedLockPath}'`
|
|
124
|
-
await ssh.execCommand(removeCommand, { cwd: remoteCwd })
|
|
125
|
-
await releaseLocalLock(rootDir, { logWarning })
|
|
126
|
-
return true
|
|
127
|
-
}
|
|
196
|
+
return true
|
|
128
197
|
}
|
|
129
198
|
|
|
130
199
|
return false
|
|
@@ -133,31 +202,70 @@ export async function compareLocksAndPrompt(rootDir, ssh, remoteCwd, {
|
|
|
133
202
|
export async function acquireRemoteLock(ssh, remoteCwd, rootDir, {
|
|
134
203
|
runPrompt,
|
|
135
204
|
logWarning,
|
|
136
|
-
|
|
205
|
+
logProcessing,
|
|
206
|
+
interactive = true,
|
|
207
|
+
wait = waitForDelay
|
|
137
208
|
} = {}) {
|
|
138
209
|
const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
|
|
139
210
|
const escapedLockPath = lockPath.replace(/'/g, "'\\''")
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
const checkResult = await ssh.execCommand(checkCommand, { cwd: remoteCwd })
|
|
211
|
+
const remoteLock = await readRemoteLock(ssh, remoteCwd)
|
|
143
212
|
|
|
144
|
-
if (
|
|
213
|
+
if (remoteLock) {
|
|
145
214
|
const localLock = await readLocalLock(rootDir)
|
|
146
215
|
if (localLock) {
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
216
|
+
const localKey = createLockKey(localLock)
|
|
217
|
+
const remoteKey = createLockKey(remoteLock)
|
|
218
|
+
|
|
219
|
+
if (localKey === remoteKey) {
|
|
220
|
+
const resolvedStaleLock = await compareLocksAndPrompt(rootDir, ssh, remoteCwd, {
|
|
221
|
+
runPrompt,
|
|
222
|
+
logWarning,
|
|
223
|
+
logProcessing,
|
|
224
|
+
interactive,
|
|
225
|
+
wait
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
if (!resolvedStaleLock) {
|
|
229
|
+
const refreshedRemoteLock = await readRemoteLock(ssh, remoteCwd)
|
|
230
|
+
if (refreshedRemoteLock) {
|
|
231
|
+
if (interactive) {
|
|
232
|
+
await promptToResolveRemoteLockConflict(rootDir, ssh, remoteCwd, refreshedRemoteLock, {
|
|
233
|
+
runPrompt,
|
|
234
|
+
logWarning,
|
|
235
|
+
logProcessing,
|
|
236
|
+
interactive,
|
|
237
|
+
wait
|
|
238
|
+
})
|
|
239
|
+
} else {
|
|
240
|
+
const { startedBy, startedAt } = formatLockHolder(refreshedRemoteLock)
|
|
241
|
+
throw new Error(
|
|
242
|
+
`Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/${lockPath} if you are sure it is stale.`
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
} else if (interactive) {
|
|
248
|
+
await promptToResolveRemoteLockConflict(rootDir, ssh, remoteCwd, remoteLock, {
|
|
249
|
+
runPrompt,
|
|
250
|
+
logWarning,
|
|
251
|
+
logProcessing,
|
|
252
|
+
interactive,
|
|
253
|
+
wait
|
|
254
|
+
})
|
|
255
|
+
} else {
|
|
256
|
+
const { startedBy, startedAt } = formatLockHolder(remoteLock)
|
|
151
257
|
throw new Error(
|
|
152
258
|
`Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/${lockPath} if you are sure it is stale.`
|
|
153
259
|
)
|
|
154
260
|
}
|
|
155
261
|
} else {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
262
|
+
await promptToResolveRemoteLockConflict(rootDir, ssh, remoteCwd, remoteLock, {
|
|
263
|
+
runPrompt,
|
|
264
|
+
logWarning,
|
|
265
|
+
logProcessing,
|
|
266
|
+
interactive,
|
|
267
|
+
wait
|
|
268
|
+
})
|
|
161
269
|
}
|
|
162
270
|
}
|
|
163
271
|
|