@wyxos/zephyr 0.4.5 → 0.4.6

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 CHANGED
@@ -68,6 +68,8 @@ When `--type node` or `--type vue` is used without a bump argument, Zephyr defau
68
68
 
69
69
  Interactive mode remains the default and is the best fit for first-time setup, config repair, and one-off deployments.
70
70
 
71
+ For app deployments, interactive mode now requires a real interactive terminal. If stdin/stdout are not attached to a TTY, Zephyr refuses to continue in interactive mode and tells you to rerun with --non-interactive --preset <name> --maintenance on|off.
72
+
71
73
  Non-interactive mode is strict and is intended for already-configured projects:
72
74
 
73
75
  - `--non-interactive` fails instead of prompting
@@ -77,6 +79,8 @@ Non-interactive mode is strict and is intended for already-configured projects:
77
79
  - stale remote locks are never auto-removed in non-interactive mode
78
80
  - `--json` is only supported together with `--non-interactive`
79
81
 
82
+ If Laravel maintenance mode has already been enabled and Zephyr then exits abnormally because of a signal such as `SIGINT`, `SIGTERM`, or `SIGHUP`, it now makes a best-effort attempt to run `artisan up` automatically before exiting.
83
+
80
84
  If Zephyr would normally prompt to:
81
85
 
82
86
  - create or repair config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
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",
@@ -59,7 +59,9 @@ async function maybeRecoverLaravelMaintenanceMode({
59
59
  runPrompt,
60
60
  logProcessing,
61
61
  logWarning,
62
- executionMode = {}
62
+ executionMode = {},
63
+ forceAutoRecovery = false,
64
+ reason = null
63
65
  } = {}) {
64
66
  if (!remotePlan?.remoteIsLaravel || !remotePlan?.maintenanceModeEnabled) {
65
67
  return
@@ -75,8 +77,11 @@ async function maybeRecoverLaravelMaintenanceMode({
75
77
  }
76
78
 
77
79
  try {
78
- if (executionMode?.interactive === false) {
79
- logProcessing?.('Deployment failed after Laravel maintenance mode was enabled. Running `artisan up` automatically...')
80
+ if (forceAutoRecovery || executionMode?.interactive === false) {
81
+ const reasonSuffix = typeof reason === 'string' && reason.length > 0
82
+ ? ` because of ${reason}`
83
+ : ''
84
+ logProcessing?.(`Deployment interrupted${reasonSuffix} after Laravel maintenance mode was enabled. Running \`artisan up\` automatically...`)
80
85
  await executeRemote(
81
86
  'Disable Laravel maintenance mode',
82
87
  remotePlan.maintenanceUpCommand ?? `${remotePlan.phpCommand} artisan up`
@@ -115,6 +120,129 @@ async function maybeRecoverLaravelMaintenanceMode({
115
120
  }
116
121
  }
117
122
 
123
+ function signalToExitCode(signal) {
124
+ const signalNumbers = {
125
+ SIGHUP: 1,
126
+ SIGINT: 2,
127
+ SIGTERM: 15
128
+ }
129
+
130
+ if (!signalNumbers[signal]) {
131
+ return null
132
+ }
133
+
134
+ return 128 + signalNumbers[signal]
135
+ }
136
+
137
+ export function createAbnormalExitGuard({
138
+ processRef = process,
139
+ cleanup = async () => {},
140
+ terminate = null,
141
+ logWarning
142
+ } = {}) {
143
+ const listeners = new Map()
144
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']
145
+ let active = true
146
+ let cleanupPromise = null
147
+
148
+ const terminateProcess = typeof terminate === 'function'
149
+ ? terminate
150
+ : async (signal) => {
151
+ const exitCode = signalToExitCode(signal)
152
+
153
+ if (typeof exitCode === 'number') {
154
+ processRef.exitCode = exitCode
155
+ }
156
+
157
+ if (typeof processRef.kill === 'function' && typeof processRef.pid === 'number') {
158
+ processRef.kill(processRef.pid, signal)
159
+ }
160
+ }
161
+
162
+ const unregister = () => {
163
+ if (!active) {
164
+ return
165
+ }
166
+
167
+ active = false
168
+
169
+ for (const [signal, handler] of listeners.entries()) {
170
+ if (typeof processRef.off === 'function') {
171
+ processRef.off(signal, handler)
172
+ continue
173
+ }
174
+
175
+ if (typeof processRef.removeListener === 'function') {
176
+ processRef.removeListener(signal, handler)
177
+ }
178
+ }
179
+
180
+ listeners.clear()
181
+ }
182
+
183
+ const run = async (signal) => {
184
+ if (!active) {
185
+ return cleanupPromise
186
+ }
187
+
188
+ if (cleanupPromise) {
189
+ return cleanupPromise
190
+ }
191
+
192
+ unregister()
193
+ cleanupPromise = (async () => {
194
+ try {
195
+ await cleanup(signal)
196
+ } catch (error) {
197
+ logWarning?.(`Best-effort deploy recovery after ${signal} failed: ${error.message}`)
198
+ } finally {
199
+ await terminateProcess(signal)
200
+ }
201
+ })()
202
+
203
+ return cleanupPromise
204
+ }
205
+
206
+ for (const signal of signals) {
207
+ const handler = () => {
208
+ void run(signal)
209
+ }
210
+
211
+ listeners.set(signal, handler)
212
+ processRef.once(signal, handler)
213
+ }
214
+
215
+ return {
216
+ unregister,
217
+ run
218
+ }
219
+ }
220
+
221
+ async function cleanupDeploymentResources({
222
+ rootDir,
223
+ ssh,
224
+ remoteCwd,
225
+ lockAcquired,
226
+ logWarning
227
+ } = {}) {
228
+ if (lockAcquired && ssh && remoteCwd) {
229
+ try {
230
+ await releaseRemoteLock(ssh, remoteCwd, {logWarning})
231
+ await releaseLocalLock(rootDir, {logWarning})
232
+ } catch (error) {
233
+ logWarning?.(`Failed to release lock: ${error.message}`)
234
+ }
235
+ }
236
+
237
+ await closeLogFile()
238
+
239
+ if (ssh) {
240
+ ssh.dispose()
241
+ }
242
+ }
243
+
244
+ export {maybeRecoverLaravelMaintenanceMode}
245
+
118
246
  export async function runDeployment(config, options = {}) {
119
247
  const {
120
248
  snapshot = null,
@@ -150,6 +278,64 @@ export async function runDeployment(config, options = {}) {
150
278
  }
151
279
 
152
280
  let lockAcquired = false
281
+ const abnormalExitGuard = createAbnormalExitGuard({
282
+ logWarning,
283
+ cleanup: async (signal) => {
284
+ let recoverySsh = null
285
+ let recoveryExecutor = executeRemote
286
+
287
+ try {
288
+ if (remoteCwd) {
289
+ ({ssh: recoverySsh} = await connectToRemoteDeploymentTarget({
290
+ config,
291
+ createSshClient,
292
+ sshUser,
293
+ privateKey,
294
+ remoteCwd,
295
+ logProcessing,
296
+ message: `Reconnecting to ${config.serverIp} as ${sshUser} for abnormal-exit recovery...`
297
+ }))
298
+
299
+ recoveryExecutor = createRemoteExecutor({
300
+ ssh: recoverySsh,
301
+ rootDir,
302
+ remoteCwd,
303
+ writeToLogFile,
304
+ logProcessing,
305
+ logSuccess,
306
+ logError
307
+ })
308
+ }
309
+
310
+ await maybeRecoverLaravelMaintenanceMode({
311
+ remotePlan,
312
+ executionState,
313
+ executeRemote: recoveryExecutor,
314
+ runPrompt,
315
+ logProcessing,
316
+ logWarning,
317
+ executionMode,
318
+ forceAutoRecovery: true,
319
+ reason: signal
320
+ })
321
+ } finally {
322
+ await cleanupDeploymentResources({
323
+ rootDir,
324
+ ssh: recoverySsh ?? ssh,
325
+ remoteCwd,
326
+ lockAcquired,
327
+ logWarning
328
+ })
329
+ lockAcquired = false
330
+
331
+ if (recoverySsh && ssh && recoverySsh !== ssh) {
332
+ ssh.dispose()
333
+ }
334
+
335
+ ssh = null
336
+ }
337
+ }
338
+ })
153
339
 
154
340
  try {
155
341
  ({ssh, remoteCwd} = await connectToRemoteDeploymentTarget({
@@ -280,18 +466,13 @@ export async function runDeployment(config, options = {}) {
280
466
 
281
467
  throw new Error(`Deployment failed: ${error.message}`)
282
468
  } finally {
283
- if (lockAcquired && ssh && remoteCwd) {
284
- try {
285
- await releaseRemoteLock(ssh, remoteCwd, {logWarning})
286
- await releaseLocalLock(rootDir, {logWarning})
287
- } catch (error) {
288
- logWarning(`Failed to release lock: ${error.message}`)
289
- }
290
- }
291
-
292
- await closeLogFile()
293
- if (ssh) {
294
- ssh.dispose()
295
- }
469
+ abnormalExitGuard.unregister()
470
+ await cleanupDeploymentResources({
471
+ rootDir,
472
+ ssh,
473
+ remoteCwd,
474
+ lockAcquired,
475
+ logWarning
476
+ })
296
477
  }
297
478
  }
package/src/main.mjs CHANGED
@@ -8,7 +8,7 @@ import {releaseNode} from './release-node.mjs'
8
8
  import {releasePackagist} from './release-packagist.mjs'
9
9
  import {validateLocalDependencies} from './dependency-scanner.mjs'
10
10
  import * as bootstrap from './project/bootstrap.mjs'
11
- import {getErrorCode} from './runtime/errors.mjs'
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
14
  import {createAppContext} from './runtime/app-context.mjs'
@@ -73,6 +73,23 @@ function resolveWorkflowName(workflowType = null) {
73
73
  return 'deploy'
74
74
  }
75
75
 
76
+ function assertInteractiveAppDeploySession({workflowType = null, executionMode = {}, appContext = {}} = {}) {
77
+ const isAppDeploy = workflowType !== 'node' && workflowType !== 'vue' && workflowType !== 'packagist'
78
+
79
+ if (!isAppDeploy || executionMode?.interactive === false) {
80
+ return
81
+ }
82
+
83
+ if (appContext?.hasInteractiveTerminal !== false) {
84
+ return
85
+ }
86
+
87
+ throw new ZephyrError(
88
+ 'Zephyr refuses interactive app deployments without a real interactive terminal. Rerun in a TTY, or use --non-interactive --preset <name> --maintenance on|off.',
89
+ {code: 'ZEPHYR_INTERACTIVE_SESSION_REQUIRED'}
90
+ )
91
+ }
92
+
76
93
  async function runRemoteTasks(config, options = {}) {
77
94
  return await runDeployment(config, {
78
95
  ...options,
@@ -131,6 +148,12 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
131
148
  logWarning(SKIP_GIT_HOOKS_WARNING)
132
149
  }
133
150
 
151
+ assertInteractiveAppDeploySession({
152
+ workflowType: options.workflowType,
153
+ executionMode: currentExecutionMode,
154
+ appContext
155
+ })
156
+
134
157
  if (options.workflowType === 'node' || options.workflowType === 'vue') {
135
158
  await releaseNode({
136
159
  releaseType: options.versionArg,
@@ -1,3 +1,4 @@
1
+ import process from 'node:process'
1
2
  import chalk from 'chalk'
2
3
  import inquirer from 'inquirer'
3
4
  import {NodeSSH} from 'node-ssh'
@@ -12,6 +13,7 @@ export function createAppContext({
12
13
  chalkInstance = chalk,
13
14
  inquirerInstance = inquirer,
14
15
  NodeSSHClass = NodeSSH,
16
+ processInstance = process,
15
17
  runCommandImpl = runCommandBase,
16
18
  runCommandCaptureImpl = runCommandCaptureBase,
17
19
  executionMode = {}
@@ -38,6 +40,7 @@ export function createAppContext({
38
40
  emitEvent,
39
41
  workflow: normalizedExecutionMode.workflow
40
42
  })
43
+ const hasInteractiveTerminal = Boolean(processInstance?.stdin?.isTTY && processInstance?.stdout?.isTTY)
41
44
  const createSshClient = createSshClientFactory({NodeSSH: NodeSSHClass})
42
45
  const {runCommand, runCommandCapture} = createLocalCommandRunners({
43
46
  runCommandBase: runCommandImpl,
@@ -59,6 +62,7 @@ export function createAppContext({
59
62
  runCommand: runCommandWithMode,
60
63
  runCommandCapture,
61
64
  emitEvent,
65
+ hasInteractiveTerminal,
62
66
  executionMode: normalizedExecutionMode
63
67
  }
64
68
  }