@wyxos/zephyr 0.3.3 → 0.4.0

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
@@ -43,6 +43,15 @@ zephyr
43
43
  # Deploy an app and bump the local npm package version first
44
44
  zephyr minor
45
45
 
46
+ # Deploy a configured app non-interactively
47
+ zephyr --non-interactive --preset wyxos-release --maintenance off
48
+
49
+ # Resume a pending non-interactive deployment
50
+ zephyr --non-interactive --preset wyxos-release --resume-pending --maintenance off
51
+
52
+ # Emit NDJSON events for automation or agent tooling
53
+ zephyr --non-interactive --json --preset wyxos-release --maintenance on
54
+
46
55
  # Release a Node/Vue package (defaults to a patch bump)
47
56
  zephyr --type node
48
57
 
@@ -55,6 +64,67 @@ zephyr --type packagist patch
55
64
 
56
65
  When `--type node` or `--type vue` is used without a bump argument, Zephyr defaults to `patch`.
57
66
 
67
+ ## Interactive and Non-Interactive Modes
68
+
69
+ Interactive mode remains the default and is the best fit for first-time setup, config repair, and one-off deployments.
70
+
71
+ Non-interactive mode is strict and is intended for already-configured projects:
72
+
73
+ - `--non-interactive` fails instead of prompting
74
+ - app deployments require `--preset <name>`
75
+ - Laravel app deployments require `--maintenance on|off` unless resuming a saved snapshot that already contains the choice
76
+ - pending deployment snapshots require either `--resume-pending` or `--discard-pending`
77
+ - stale remote locks are never auto-removed in non-interactive mode
78
+ - `--json` is only supported together with `--non-interactive`
79
+
80
+ If Zephyr would normally prompt to:
81
+
82
+ - create or repair config
83
+ - save a preset
84
+ - install the `release` script
85
+ - confirm local path dependency updates
86
+ - resolve a stale remote lock
87
+
88
+ then non-interactive mode stops immediately with a clear error instead.
89
+
90
+ ## AI Agents and Automation
91
+
92
+ Zephyr can be used safely by Codex, CI jobs, or other automation once configuration is already in place.
93
+
94
+ Recommended pattern for app deployments:
95
+
96
+ ```bash
97
+ zephyr --non-interactive --json --preset wyxos-release --maintenance off
98
+ ```
99
+
100
+ Recommended pattern for package releases:
101
+
102
+ ```bash
103
+ zephyr --type node --non-interactive --json minor
104
+ zephyr --type packagist --non-interactive --json patch
105
+ ```
106
+
107
+ In `--json` mode Zephyr emits NDJSON events on `stdout` with a stable shape:
108
+
109
+ - `run_started`
110
+ - `log`
111
+ - `prompt_required`
112
+ - `run_completed`
113
+ - `run_failed`
114
+
115
+ Each event includes:
116
+
117
+ - `event`
118
+ - `timestamp`
119
+ - `workflow`
120
+ - `message`
121
+ - `level` where relevant
122
+ - `data`
123
+
124
+ `run_failed` also includes a stable `code` field for automation checks.
125
+
126
+ In `--json` mode, Zephyr reserves `stdout` for NDJSON events and routes inherited local command output to `stderr` so agent parsers do not get corrupted.
127
+
58
128
  On a first run inside a project with `package.json`, Zephyr can:
59
129
  - add `.zephyr/` to `.gitignore`
60
130
  - add a `release` script that runs `npx @wyxos/zephyr@latest`
@@ -79,6 +149,7 @@ npm run release
79
149
  - `npm run release` is the recommended app/package entrypoint once the release script has been installed.
80
150
  - For `--type node` workflows, Zephyr runs your project's `lint` script when present.
81
151
  - For `--type node` workflows, Zephyr runs `test:run` or `test` when present.
152
+ - For non-interactive app deploys, use a saved preset name instead of relying on prompt fallback.
82
153
 
83
154
  ## Features
84
155
 
package/bin/zephyr.mjs CHANGED
@@ -1,30 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  import process from 'node:process'
3
- import { Command } from 'commander'
4
- import chalk from 'chalk'
5
- import { main } from '../src/main.mjs'
6
- import { createChalkLogger } from '../src/utils/output.mjs'
7
3
 
8
- const { logError } = createChalkLogger(chalk)
9
-
10
- const program = new Command()
11
-
12
- program
13
- .name('zephyr')
14
- .description('A streamlined deployment tool for web applications with intelligent Laravel project detection')
15
- .option('--type <type>', 'Workflow type (node|vue|packagist). Omit for normal app deployments.')
16
- .argument(
17
- '[version]',
18
- 'Version or npm bump type for deployments (e.g. 1.2.3, patch, minor, major). --type node/vue/packagist workflows accept bump types only and default to patch.'
19
- )
20
-
21
- program.parse(process.argv)
22
- const options = program.opts()
23
- const versionArg = program.args[0] ?? null
4
+ import {parseCliOptions} from '../src/cli/options.mjs'
5
+ import {main} from '../src/main.mjs'
6
+ import {writeStderrLine} from '../src/utils/output.mjs'
24
7
 
8
+ let options
25
9
  try {
26
- await main(options.type ?? null, versionArg)
10
+ options = parseCliOptions()
27
11
  } catch (error) {
28
- logError(error?.message || String(error))
29
- process.exitCode = 1
12
+ writeStderrLine(error.message)
13
+ process.exitCode = 1
14
+ }
15
+
16
+ if (options) {
17
+ try {
18
+ await main(options)
19
+ } catch {
20
+ process.exitCode = 1
21
+ }
30
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.3.3",
3
+ "version": "0.4.0",
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",
@@ -1,22 +1,82 @@
1
1
  import {writeStdoutLine} from '../../utils/output.mjs'
2
2
  import {loadServers} from '../../config/servers.mjs'
3
3
  import {loadProjectConfig, removePreset, saveProjectConfig} from '../../config/project.mjs'
4
+ import {ZephyrError} from '../../runtime/errors.mjs'
5
+
6
+ function findPresetByName(projectConfig, presetName) {
7
+ const presets = projectConfig?.presets ?? []
8
+ return presets.find((entry) => entry?.name === presetName) ?? null
9
+ }
10
+
11
+ function resolvePresetNonInteractive(projectConfig, servers, preset, presetName) {
12
+ if (!preset) {
13
+ throw new ZephyrError(
14
+ `Zephyr cannot run non-interactively because preset "${presetName}" was not found in .zephyr/config.json.`,
15
+ {code: 'ZEPHYR_PRESET_NOT_FOUND'}
16
+ )
17
+ }
18
+
19
+ if (!preset.appId) {
20
+ throw new ZephyrError(
21
+ `Zephyr cannot run non-interactively because preset "${preset.name || presetName}" uses a legacy or invalid format. Rerun interactively once to repair .zephyr/config.json.`,
22
+ {code: 'ZEPHYR_PRESET_REPAIR_REQUIRED'}
23
+ )
24
+ }
25
+
26
+ const apps = projectConfig?.apps ?? []
27
+ const appConfig = apps.find((app) => app.id === preset.appId)
28
+ if (!appConfig) {
29
+ throw new ZephyrError(
30
+ `Zephyr cannot run non-interactively because preset "${preset.name || presetName}" references an application that no longer exists.`,
31
+ {code: 'ZEPHYR_PRESET_INVALID'}
32
+ )
33
+ }
34
+
35
+ const server = servers.find((entry) => entry.id === appConfig.serverId || entry.serverName === appConfig.serverName)
36
+ if (!server) {
37
+ throw new ZephyrError(
38
+ `Zephyr cannot run non-interactively because preset "${preset.name || presetName}" references a server that no longer exists.`,
39
+ {code: 'ZEPHYR_PRESET_INVALID'}
40
+ )
41
+ }
42
+
43
+ return {
44
+ server,
45
+ appConfig,
46
+ branch: preset.branch || appConfig.branch
47
+ }
48
+ }
4
49
 
5
50
  export async function selectDeploymentTarget(rootDir, {
6
51
  configurationService,
7
52
  runPrompt,
8
53
  logProcessing,
9
54
  logSuccess,
10
- logWarning
55
+ logWarning,
56
+ emitEvent,
57
+ executionMode = {}
11
58
  } = {}) {
12
- const servers = await loadServers({logSuccess, logWarning})
13
- const projectConfig = await loadProjectConfig(rootDir, servers, {logSuccess, logWarning})
59
+ const nonInteractive = executionMode?.interactive === false
60
+ const servers = await loadServers({
61
+ logSuccess,
62
+ logWarning,
63
+ strict: nonInteractive,
64
+ allowMigration: !nonInteractive
65
+ })
66
+ const projectConfig = await loadProjectConfig(rootDir, servers, {
67
+ logSuccess,
68
+ logWarning,
69
+ strict: nonInteractive,
70
+ allowMigration: !nonInteractive
71
+ })
14
72
 
15
73
  let server = null
16
74
  let appConfig = null
17
75
  let isCreatingNewPreset = false
18
76
 
19
- const preset = await configurationService.selectPreset(projectConfig, servers)
77
+ const preset = nonInteractive
78
+ ? findPresetByName(projectConfig, executionMode.presetName)
79
+ : await configurationService.selectPreset(projectConfig, servers)
20
80
 
21
81
  const removeInvalidPreset = async () => {
22
82
  if (!preset || preset === 'create') {
@@ -34,7 +94,15 @@ export async function selectDeploymentTarget(rootDir, {
34
94
  isCreatingNewPreset = true
35
95
  }
36
96
 
37
- if (preset === 'create') {
97
+ if (nonInteractive) {
98
+ const resolved = resolvePresetNonInteractive(projectConfig, servers, preset, executionMode.presetName)
99
+ server = resolved.server
100
+ appConfig = resolved.appConfig
101
+ appConfig = {
102
+ ...appConfig,
103
+ branch: resolved.branch
104
+ }
105
+ } else if (preset === 'create') {
38
106
  isCreatingNewPreset = true
39
107
  server = await configurationService.selectServer(servers)
40
108
  appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
@@ -103,7 +171,16 @@ export async function selectDeploymentTarget(rootDir, {
103
171
  appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
104
172
  }
105
173
 
106
- const updated = await configurationService.ensureSshDetails(appConfig, rootDir)
174
+ if (nonInteractive && (!appConfig?.sshUser || !appConfig?.sshKey)) {
175
+ throw new ZephyrError(
176
+ `Zephyr cannot run non-interactively because preset "${preset?.name || executionMode.presetName}" is missing SSH details.`,
177
+ {code: 'ZEPHYR_SSH_DETAILS_REQUIRED'}
178
+ )
179
+ }
180
+
181
+ const updated = nonInteractive
182
+ ? false
183
+ : await configurationService.ensureSshDetails(appConfig, rootDir)
107
184
 
108
185
  if (updated) {
109
186
  await saveProjectConfig(rootDir, projectConfig)
@@ -119,10 +196,18 @@ export async function selectDeploymentTarget(rootDir, {
119
196
  sshKey: appConfig.sshKey
120
197
  }
121
198
 
122
- logProcessing?.('\nSelected deployment target:')
123
- writeStdoutLine(JSON.stringify(deploymentConfig, null, 2))
199
+ if (typeof emitEvent === 'function' && executionMode?.json) {
200
+ emitEvent('log', {
201
+ level: 'processing',
202
+ message: 'Selected deployment target.',
203
+ data: {deploymentConfig}
204
+ })
205
+ } else {
206
+ logProcessing?.('\nSelected deployment target:')
207
+ writeStdoutLine(JSON.stringify(deploymentConfig, null, 2))
208
+ }
124
209
 
125
- if (isCreatingNewPreset || !preset) {
210
+ if (!nonInteractive && (isCreatingNewPreset || !preset)) {
126
211
  const {presetName} = await runPrompt([
127
212
  {
128
213
  type: 'input',
@@ -1,8 +1,116 @@
1
1
  import {findPhpBinary} from '../../infrastructure/php/version.mjs'
2
+ import {ZephyrError} from '../../runtime/errors.mjs'
2
3
  import {planLaravelDeploymentTasks} from './plan-laravel-deployment-tasks.mjs'
3
4
 
4
5
  const PRERENDERED_MAINTENANCE_VIEW = 'errors::503'
5
6
  const PRERENDERED_MAINTENANCE_FILE = 'resources/views/errors/503.blade.php'
7
+ const LARAVEL_WRITABLE_PATHS = [
8
+ 'bootstrap/cache',
9
+ 'storage/framework/cache',
10
+ 'storage/framework/views',
11
+ 'storage/framework/sessions'
12
+ ]
13
+
14
+ function escapeForSingleQuotes(value) {
15
+ return value.replace(/'/g, "'\\''")
16
+ }
17
+
18
+ function isGroupWritable(mode) {
19
+ if (typeof mode !== 'string' || mode.length < 2) {
20
+ return false
21
+ }
22
+
23
+ const groupDigit = mode.at(-2)
24
+ const parsed = Number.parseInt(groupDigit, 8)
25
+ return Number.isInteger(parsed) && (parsed & 2) === 2
26
+ }
27
+
28
+ function shouldInspectLaravelWritablePaths(steps = []) {
29
+ return steps.some((step) => step.label === 'Clear Laravel caches')
30
+ }
31
+
32
+ async function inspectLaravelWritablePath(ssh, remoteCwd, relativePath) {
33
+ const escapedPath = escapeForSingleQuotes(relativePath)
34
+ const command = [
35
+ `if [ ! -e '${escapedPath}' ]; then`,
36
+ ' printf "__MISSING__";',
37
+ 'else',
38
+ ` WRITABLE="no"; [ -w '${escapedPath}' ] && WRITABLE="yes";`,
39
+ ` OWNER=$(stat -c '%U' '${escapedPath}' 2>/dev/null || printf '?');`,
40
+ ` GROUP=$(stat -c '%G' '${escapedPath}' 2>/dev/null || printf '?');`,
41
+ ` MODE=$(stat -c '%a' '${escapedPath}' 2>/dev/null || printf '?');`,
42
+ ' printf "%s|%s|%s|%s" "$WRITABLE" "$OWNER" "$GROUP" "$MODE";',
43
+ 'fi'
44
+ ].join(' ')
45
+
46
+ const result = await ssh.execCommand(command, {cwd: remoteCwd})
47
+ const output = result.stdout.trim()
48
+
49
+ if (output === '__MISSING__') {
50
+ return {
51
+ path: relativePath,
52
+ exists: false,
53
+ writable: false,
54
+ owner: null,
55
+ group: null,
56
+ mode: null
57
+ }
58
+ }
59
+
60
+ const [writableFlag, owner = '?', group = '?', mode = '?'] = output.split('|')
61
+
62
+ return {
63
+ path: relativePath,
64
+ exists: true,
65
+ writable: writableFlag === 'yes',
66
+ owner,
67
+ group,
68
+ mode
69
+ }
70
+ }
71
+
72
+ async function validateLaravelWritablePaths({
73
+ ssh,
74
+ remoteCwd,
75
+ sshUser,
76
+ steps,
77
+ logProcessing,
78
+ logWarning
79
+ } = {}) {
80
+ if (!shouldInspectLaravelWritablePaths(steps)) {
81
+ return
82
+ }
83
+
84
+ logProcessing?.(`Checking Laravel writable directories for deploy user ${sshUser || 'current SSH user'}...`)
85
+
86
+ const inspections = []
87
+ for (const relativePath of LARAVEL_WRITABLE_PATHS) {
88
+ inspections.push(await inspectLaravelWritablePath(ssh, remoteCwd, relativePath))
89
+ }
90
+
91
+ const blockedPaths = inspections.filter((inspection) => inspection.exists && !inspection.writable)
92
+ if (blockedPaths.length > 0) {
93
+ const details = blockedPaths
94
+ .map((inspection) => ` - ${inspection.path} (owner ${inspection.owner}:${inspection.group}, mode ${inspection.mode})`)
95
+ .join('\n')
96
+
97
+ throw new Error(
98
+ 'Laravel cache-related deployment tasks cannot run because the SSH deploy user cannot write to required directories:\n' +
99
+ `${details}\n` +
100
+ 'Fix permissions before releasing. Typical fix:\n' +
101
+ 'sudo chown -R $USER:www-data bootstrap/cache storage/framework/cache storage/framework/views storage/framework/sessions\n' +
102
+ 'sudo chmod -R ug+rwX bootstrap/cache storage/framework/cache storage/framework/views storage/framework/sessions'
103
+ )
104
+ }
105
+
106
+ const riskyPaths = inspections.filter((inspection) => inspection.exists && inspection.writable && !isGroupWritable(inspection.mode))
107
+ for (const inspection of riskyPaths) {
108
+ logWarning?.(
109
+ `${inspection.path} is writable by the deploy user (${inspection.owner}:${inspection.group}, mode ${inspection.mode}), ` +
110
+ 'but it is not group-writable. Web-created cache files may cause later permission drift.'
111
+ )
112
+ }
113
+ }
6
114
 
7
115
  async function detectRemoteLaravelProject(ssh, remoteCwd) {
8
116
  const laravelCheck = await ssh.execCommand(
@@ -134,7 +242,8 @@ function createMaintenanceModePlan({
134
242
  async function resolveMaintenanceMode({
135
243
  snapshot,
136
244
  remoteIsLaravel,
137
- runPrompt
245
+ runPrompt,
246
+ executionMode = {}
138
247
  } = {}) {
139
248
  if (!remoteIsLaravel) {
140
249
  return false
@@ -144,6 +253,17 @@ async function resolveMaintenanceMode({
144
253
  return snapshot.maintenanceModeEnabled
145
254
  }
146
255
 
256
+ if (executionMode?.interactive === false) {
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
+
264
+ return executionMode.maintenanceMode
265
+ }
266
+
147
267
  if (typeof runPrompt !== 'function') {
148
268
  return false
149
269
  }
@@ -222,6 +342,7 @@ export async function buildRemoteDeploymentPlan({
222
342
  config,
223
343
  snapshot = null,
224
344
  requiredPhpVersion = null,
345
+ executionMode = {},
225
346
  ssh,
226
347
  remoteCwd,
227
348
  executeRemote,
@@ -264,7 +385,8 @@ export async function buildRemoteDeploymentPlan({
264
385
  const maintenanceModeEnabled = await resolveMaintenanceMode({
265
386
  snapshot,
266
387
  remoteIsLaravel,
267
- runPrompt
388
+ runPrompt,
389
+ executionMode
268
390
  })
269
391
 
270
392
  const maintenanceModePlan = await resolveMaintenanceModePlan({
@@ -290,6 +412,15 @@ export async function buildRemoteDeploymentPlan({
290
412
  maintenanceUpCommand: maintenanceModePlan.upCommand
291
413
  })
292
414
 
415
+ await validateLaravelWritablePaths({
416
+ ssh,
417
+ remoteCwd,
418
+ sshUser: config.sshUser,
419
+ steps,
420
+ logProcessing,
421
+ logWarning
422
+ })
423
+
293
424
  const usefulSteps = steps.length > 1
294
425
  const pendingSnapshot = !usefulSteps
295
426
  ? null
@@ -320,4 +451,4 @@ export async function buildRemoteDeploymentPlan({
320
451
  usefulSteps,
321
452
  pendingSnapshot
322
453
  }
323
- }
454
+ }
@@ -46,7 +46,7 @@ export async function executeRemoteDeploymentPlan({
46
46
  usefulSteps,
47
47
  pendingSnapshot = null,
48
48
  logProcessing,
49
- executionState = null
49
+ executionState = undefined
50
50
  } = {}) {
51
51
  if (usefulSteps && pendingSnapshot) {
52
52
  await persistPendingSnapshot(rootDir, pendingSnapshot, executeRemote)
@@ -73,4 +73,4 @@ export async function executeRemoteDeploymentPlan({
73
73
  await clearPendingTasksSnapshot(rootDir)
74
74
  }
75
75
  }
76
- }
76
+ }
@@ -1,9 +1,11 @@
1
1
  import {clearPendingTasksSnapshot, loadPendingTasksSnapshot} from '../../deploy/snapshots.mjs'
2
+ import {ZephyrError} from '../../runtime/errors.mjs'
2
3
 
3
4
  export async function resolvePendingSnapshot(rootDir, deploymentConfig, {
4
5
  runPrompt,
5
6
  logProcessing,
6
- logWarning
7
+ logWarning,
8
+ executionMode = {}
7
9
  } = {}) {
8
10
  const existingSnapshot = await loadPendingTasksSnapshot(rootDir)
9
11
 
@@ -25,6 +27,24 @@ export async function resolvePendingSnapshot(rootDir, deploymentConfig, {
25
27
  messageLines.push(`Tasks: ${existingSnapshot.taskLabels.join(', ')}`)
26
28
  }
27
29
 
30
+ if (executionMode?.interactive === false) {
31
+ if (!executionMode.resumePending && !executionMode.discardPending) {
32
+ throw new ZephyrError(
33
+ 'Zephyr found a pending deployment snapshot, but non-interactive mode requires either --resume-pending or --discard-pending.',
34
+ {code: 'ZEPHYR_PENDING_SNAPSHOT_ACTION_REQUIRED'}
35
+ )
36
+ }
37
+
38
+ if (executionMode.resumePending) {
39
+ logProcessing?.('Resuming deployment using saved task snapshot...')
40
+ return existingSnapshot
41
+ }
42
+
43
+ await clearPendingTasksSnapshot(rootDir)
44
+ logWarning?.('Discarded pending deployment snapshot.')
45
+ return null
46
+ }
47
+
28
48
  const {resumePendingTasks} = await runPrompt([
29
49
  {
30
50
  type: 'confirm',
@@ -26,7 +26,9 @@ async function maybeRecoverLaravelMaintenanceMode({
26
26
  executionState,
27
27
  executeRemote,
28
28
  runPrompt,
29
- logWarning
29
+ logProcessing,
30
+ logWarning,
31
+ executionMode = {}
30
32
  } = {}) {
31
33
  if (!remotePlan?.remoteIsLaravel || !remotePlan?.maintenanceModeEnabled) {
32
34
  return
@@ -36,12 +38,27 @@ async function maybeRecoverLaravelMaintenanceMode({
36
38
  return
37
39
  }
38
40
 
39
- if (typeof runPrompt !== 'function' || typeof executeRemote !== 'function') {
41
+ if (typeof executeRemote !== 'function') {
40
42
  logWarning?.('Deployment failed while Laravel maintenance mode may still be enabled.')
41
43
  return
42
44
  }
43
45
 
44
46
  try {
47
+ if (executionMode?.interactive === false) {
48
+ logProcessing?.('Deployment failed after Laravel maintenance mode was enabled. Running `artisan up` automatically...')
49
+ await executeRemote(
50
+ 'Disable Laravel maintenance mode',
51
+ remotePlan.maintenanceUpCommand ?? `${remotePlan.phpCommand} artisan up`
52
+ )
53
+ executionState.exitedMaintenanceMode = true
54
+ return
55
+ }
56
+
57
+ if (typeof runPrompt !== 'function') {
58
+ logWarning?.('Deployment failed while Laravel maintenance mode may still be enabled.')
59
+ return
60
+ }
61
+
45
62
  const answers = await runPrompt([
46
63
  {
47
64
  type: 'confirm',
@@ -82,7 +99,8 @@ export async function runDeployment(config, options = {}) {
82
99
  logError,
83
100
  runPrompt,
84
101
  createSshClient,
85
- runCommand
102
+ runCommand,
103
+ executionMode
86
104
  } = context
87
105
 
88
106
  await cleanupOldLogs(rootDir)
@@ -126,7 +144,11 @@ export async function runDeployment(config, options = {}) {
126
144
  remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
127
145
 
128
146
  logProcessing('Connection established. Acquiring deployment lock on server...')
129
- await acquireRemoteLock(ssh, remoteCwd, rootDir, {runPrompt, logWarning})
147
+ await acquireRemoteLock(ssh, remoteCwd, rootDir, {
148
+ runPrompt,
149
+ logWarning,
150
+ interactive: executionMode?.interactive !== false
151
+ })
130
152
  lockAcquired = true
131
153
  logProcessing(`Lock acquired. Running deployment commands in ${remoteCwd}...`)
132
154
 
@@ -145,6 +167,7 @@ export async function runDeployment(config, options = {}) {
145
167
  snapshot,
146
168
  rootDir,
147
169
  requiredPhpVersion,
170
+ executionMode,
148
171
  ssh,
149
172
  remoteCwd,
150
173
  executeRemote,
@@ -179,12 +202,18 @@ export async function runDeployment(config, options = {}) {
179
202
  executionState,
180
203
  executeRemote,
181
204
  runPrompt,
182
- logWarning
205
+ logProcessing,
206
+ logWarning,
207
+ executionMode
183
208
  })
184
209
 
185
210
  if (lockAcquired && ssh && remoteCwd) {
186
211
  try {
187
- await compareLocksAndPrompt(rootDir, ssh, remoteCwd, {runPrompt, logWarning})
212
+ await compareLocksAndPrompt(rootDir, ssh, remoteCwd, {
213
+ runPrompt,
214
+ logWarning,
215
+ interactive: executionMode?.interactive !== false
216
+ })
188
217
  } catch {
189
218
  // Ignore lock comparison errors during error handling
190
219
  }
@@ -206,4 +235,4 @@ export async function runDeployment(config, options = {}) {
206
235
  ssh.dispose()
207
236
  }
208
237
  }
209
- }
238
+ }