@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 +4 -0
- package/package.json +1 -1
- package/src/application/deploy/run-deployment.mjs +197 -16
- package/src/main.mjs +24 -1
- package/src/runtime/app-context.mjs +4 -0
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
|
@@ -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
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
}
|