@wyxos/zephyr 0.4.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "A streamlined deployment tool for web applications with intelligent Laravel project detection",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -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. Built-in release checks are supported, and Zephyr will skip executing them here. WARNING: --skip-git-hooks is enabled, so Zephyr will also bypass that hook if it needs to push local commits during this release.'
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, {
@@ -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 parseLockDetails(rawContent = '') {
72
- try {
73
- return JSON.parse(rawContent.trim())
74
- } catch (_error) {
75
- return { raw: rawContent.trim() }
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
- interactive = true
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 = `${localLock.user}@${localLock.hostname}:${localLock.pid}:${localLock.startedAt}`
99
- const remoteKey = `${remoteLock.user}@${remoteLock.hostname}:${remoteLock.pid}:${remoteLock.startedAt}`
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
- const { shouldRemove } = await runPrompt([
112
- {
113
- type: 'confirm',
114
- name: 'shouldRemove',
115
- message: `Stale lock detected on server (started by ${startedBy}${startedAt}). This appears to be from a failed deployment. Remove it?`,
116
- default: true
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
- if (shouldRemove) {
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
- interactive = true
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 checkCommand = `mkdir -p .zephyr && if [ -f '${escapedLockPath}' ]; then cat '${escapedLockPath}'; else echo "LOCK_NOT_FOUND"; fi`
141
-
142
- const checkResult = await ssh.execCommand(checkCommand, { cwd: remoteCwd })
211
+ const remoteLock = await readRemoteLock(ssh, remoteCwd)
143
212
 
144
- if (checkResult.stdout && checkResult.stdout.trim() !== 'LOCK_NOT_FOUND' && checkResult.stdout.trim() !== '') {
213
+ if (remoteLock) {
145
214
  const localLock = await readLocalLock(rootDir)
146
215
  if (localLock) {
147
- const removed = await compareLocksAndPrompt(rootDir, ssh, remoteCwd, { runPrompt, logWarning, interactive })
148
- if (!removed) {
149
- const details = parseLockDetails(checkResult.stdout.trim())
150
- const { startedBy, startedAt } = formatLockHolder(details)
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
- const details = parseLockDetails(checkResult.stdout.trim())
157
- const { startedBy, startedAt } = formatLockHolder(details)
158
- throw new Error(
159
- `Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/${lockPath} if you are sure it is stale.`
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