@wyxos/zephyr 0.3.2 → 0.3.3

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.3.2",
3
+ "version": "0.3.3",
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,6 +1,9 @@
1
1
  import {findPhpBinary} from '../../infrastructure/php/version.mjs'
2
2
  import {planLaravelDeploymentTasks} from './plan-laravel-deployment-tasks.mjs'
3
3
 
4
+ const PRERENDERED_MAINTENANCE_VIEW = 'errors::503'
5
+ const PRERENDERED_MAINTENANCE_FILE = 'resources/views/errors/503.blade.php'
6
+
4
7
  async function detectRemoteLaravelProject(ssh, remoteCwd) {
5
8
  const laravelCheck = await ssh.execCommand(
6
9
  'if [ -f artisan ] && [ -f composer.json ] && grep -q "laravel/framework" composer.json; then echo "yes"; else echo "no"; fi',
@@ -99,6 +102,122 @@ async function resolvePhpCommand({
99
102
  }
100
103
  }
101
104
 
105
+ function createMaintenanceModePlan({
106
+ enabled,
107
+ phpCommand,
108
+ usesPrerender = false,
109
+ renderView = null
110
+ } = {}) {
111
+ const maintenanceUpCommand = `${phpCommand} artisan up`
112
+
113
+ if (!enabled) {
114
+ return {
115
+ enabled: false,
116
+ usesPrerender: false,
117
+ renderView: null,
118
+ downCommand: null,
119
+ upCommand: maintenanceUpCommand
120
+ }
121
+ }
122
+
123
+ return {
124
+ enabled: true,
125
+ usesPrerender,
126
+ renderView: usesPrerender ? renderView : null,
127
+ downCommand: usesPrerender && renderView
128
+ ? `${phpCommand} artisan down --render="${renderView}"`
129
+ : `${phpCommand} artisan down`,
130
+ upCommand: maintenanceUpCommand
131
+ }
132
+ }
133
+
134
+ async function resolveMaintenanceMode({
135
+ snapshot,
136
+ remoteIsLaravel,
137
+ runPrompt
138
+ } = {}) {
139
+ if (!remoteIsLaravel) {
140
+ return false
141
+ }
142
+
143
+ if (typeof snapshot?.maintenanceModeEnabled === 'boolean') {
144
+ return snapshot.maintenanceModeEnabled
145
+ }
146
+
147
+ if (typeof runPrompt !== 'function') {
148
+ return false
149
+ }
150
+
151
+ const answers = await runPrompt([
152
+ {
153
+ type: 'confirm',
154
+ name: 'enableMaintenanceMode',
155
+ message: 'Enable Laravel maintenance mode for this deployment? (`artisan down` before deploy, `artisan up` after)',
156
+ default: false
157
+ }
158
+ ])
159
+
160
+ return Boolean(answers?.enableMaintenanceMode)
161
+ }
162
+
163
+ async function resolveMaintenanceModePlan({
164
+ snapshot,
165
+ remoteIsLaravel,
166
+ remoteCwd,
167
+ maintenanceModeEnabled,
168
+ phpCommand,
169
+ ssh,
170
+ executeRemote,
171
+ logProcessing,
172
+ logWarning
173
+ } = {}) {
174
+ if (!remoteIsLaravel || !maintenanceModeEnabled) {
175
+ return createMaintenanceModePlan({enabled: false, phpCommand})
176
+ }
177
+
178
+ if (typeof snapshot?.maintenanceModeUsesPrerender === 'boolean') {
179
+ return createMaintenanceModePlan({
180
+ enabled: true,
181
+ phpCommand,
182
+ usesPrerender: snapshot.maintenanceModeUsesPrerender,
183
+ renderView: snapshot.maintenanceModeRenderView ?? PRERENDERED_MAINTENANCE_VIEW
184
+ })
185
+ }
186
+
187
+ const capabilityResult = await executeRemote(
188
+ 'Inspect Laravel maintenance mode capabilities',
189
+ `${phpCommand} artisan down --help`,
190
+ {printStdout: false, allowFailure: true}
191
+ )
192
+ const helpOutput = `${capabilityResult.stdout ?? ''}\n${capabilityResult.stderr ?? ''}`
193
+ const supportsPrerender = capabilityResult.code === 0 && /(^|\s)--render(?:\[|[=\s]|$)/m.test(helpOutput)
194
+
195
+ if (!supportsPrerender) {
196
+ logProcessing?.('Prerendered Laravel maintenance mode is unavailable on the remote app; using standard maintenance mode.')
197
+ return createMaintenanceModePlan({enabled: true, phpCommand})
198
+ }
199
+
200
+ const maintenanceViewCheck = await ssh.execCommand(
201
+ `if [ -f ${PRERENDERED_MAINTENANCE_FILE} ]; then echo "yes"; else echo "no"; fi`,
202
+ {cwd: remoteCwd}
203
+ )
204
+
205
+ if (maintenanceViewCheck.stdout.trim() !== 'yes') {
206
+ logWarning?.(
207
+ `Laravel supports prerendered maintenance mode, but ${PRERENDERED_MAINTENANCE_FILE} is missing; using standard maintenance mode.`
208
+ )
209
+ return createMaintenanceModePlan({enabled: true, phpCommand})
210
+ }
211
+
212
+ logProcessing?.(`Using prerendered Laravel maintenance response (${PRERENDERED_MAINTENANCE_VIEW}).`)
213
+ return createMaintenanceModePlan({
214
+ enabled: true,
215
+ phpCommand,
216
+ usesPrerender: true,
217
+ renderView: PRERENDERED_MAINTENANCE_VIEW
218
+ })
219
+ }
220
+
102
221
  export async function buildRemoteDeploymentPlan({
103
222
  config,
104
223
  snapshot = null,
@@ -108,7 +227,8 @@ export async function buildRemoteDeploymentPlan({
108
227
  executeRemote,
109
228
  logProcessing,
110
229
  logSuccess,
111
- logWarning
230
+ logWarning,
231
+ runPrompt
112
232
  } = {}) {
113
233
  const remoteIsLaravel = await detectRemoteLaravelProject(ssh, remoteCwd)
114
234
 
@@ -141,12 +261,33 @@ export async function buildRemoteDeploymentPlan({
141
261
  logWarning
142
262
  })
143
263
 
264
+ const maintenanceModeEnabled = await resolveMaintenanceMode({
265
+ snapshot,
266
+ remoteIsLaravel,
267
+ runPrompt
268
+ })
269
+
270
+ const maintenanceModePlan = await resolveMaintenanceModePlan({
271
+ snapshot,
272
+ remoteIsLaravel,
273
+ remoteCwd,
274
+ maintenanceModeEnabled,
275
+ phpCommand,
276
+ ssh,
277
+ executeRemote,
278
+ logProcessing,
279
+ logWarning
280
+ })
281
+
144
282
  const steps = planLaravelDeploymentTasks({
145
283
  branch: config.branch,
146
284
  isLaravel: remoteIsLaravel,
147
285
  changedFiles,
148
286
  horizonConfigured,
149
- phpCommand
287
+ phpCommand,
288
+ maintenanceMode: maintenanceModePlan.enabled,
289
+ maintenanceDownCommand: maintenanceModePlan.downCommand,
290
+ maintenanceUpCommand: maintenanceModePlan.upCommand
150
291
  })
151
292
 
152
293
  const usefulSteps = steps.length > 1
@@ -158,6 +299,9 @@ export async function buildRemoteDeploymentPlan({
158
299
  projectPath: config.projectPath,
159
300
  sshUser: config.sshUser,
160
301
  createdAt: new Date().toISOString(),
302
+ maintenanceModeEnabled: maintenanceModePlan.enabled,
303
+ maintenanceModeUsesPrerender: maintenanceModePlan.usesPrerender,
304
+ maintenanceModeRenderView: maintenanceModePlan.renderView,
161
305
  changedFiles,
162
306
  taskLabels: steps.map((step) => step.label)
163
307
  }
@@ -167,8 +311,13 @@ export async function buildRemoteDeploymentPlan({
167
311
  changedFiles,
168
312
  horizonConfigured,
169
313
  phpCommand,
314
+ maintenanceModeEnabled: maintenanceModePlan.enabled,
315
+ maintenanceModeUsesPrerender: maintenanceModePlan.usesPrerender,
316
+ maintenanceModeRenderView: maintenanceModePlan.renderView,
317
+ maintenanceDownCommand: maintenanceModePlan.downCommand,
318
+ maintenanceUpCommand: maintenanceModePlan.upCommand,
170
319
  steps,
171
320
  usefulSteps,
172
321
  pendingSnapshot
173
322
  }
174
- }
323
+ }
@@ -13,17 +13,30 @@ async function persistPendingSnapshot(rootDir, pendingSnapshot, executeRemote) {
13
13
  }
14
14
 
15
15
  function logScheduledTasks(steps, {logProcessing} = {}) {
16
- if (steps.length === 1) {
16
+ const extraTasks = steps
17
+ .filter((step) => !step.command.startsWith('git pull '))
18
+ .map((step) => ` - ${step.label}`)
19
+
20
+ if (extraTasks.length === 0) {
17
21
  logProcessing?.('No additional maintenance tasks scheduled beyond git pull.')
18
22
  return
19
23
  }
20
24
 
21
- const extraTasks = steps
22
- .slice(1)
23
- .map((step) => ` - ${step.label}`)
24
- .join('\n')
25
+ logProcessing?.(`Additional tasks scheduled:\n${extraTasks.join('\n')}`)
26
+ }
25
27
 
26
- logProcessing?.(`Additional tasks scheduled:\n${extraTasks}`)
28
+ function trackExecutionState(step, executionState) {
29
+ if (!executionState || !step?.kind) {
30
+ return
31
+ }
32
+
33
+ if (step.kind === 'maintenance-down') {
34
+ executionState.enteredMaintenanceMode = true
35
+ }
36
+
37
+ if (step.kind === 'maintenance-up') {
38
+ executionState.exitedMaintenanceMode = true
39
+ }
27
40
  }
28
41
 
29
42
  export async function executeRemoteDeploymentPlan({
@@ -32,7 +45,8 @@ export async function executeRemoteDeploymentPlan({
32
45
  steps,
33
46
  usefulSteps,
34
47
  pendingSnapshot = null,
35
- logProcessing
48
+ logProcessing,
49
+ executionState = null
36
50
  } = {}) {
37
51
  if (usefulSteps && pendingSnapshot) {
38
52
  await persistPendingSnapshot(rootDir, pendingSnapshot, executeRemote)
@@ -45,6 +59,7 @@ export async function executeRemoteDeploymentPlan({
45
59
  try {
46
60
  for (const step of steps) {
47
61
  await executeRemote(step.label, step.command)
62
+ trackExecutionState(step, executionState)
48
63
  }
49
64
 
50
65
  completed = true
@@ -58,4 +73,4 @@ export async function executeRemoteDeploymentPlan({
58
73
  await clearPendingTasksSnapshot(rootDir)
59
74
  }
60
75
  }
61
- }
76
+ }
@@ -3,7 +3,10 @@ export function planLaravelDeploymentTasks({
3
3
  isLaravel,
4
4
  changedFiles,
5
5
  horizonConfigured = false,
6
- phpCommand = 'php'
6
+ phpCommand = 'php',
7
+ maintenanceMode = false,
8
+ maintenanceDownCommand = null,
9
+ maintenanceUpCommand = null
7
10
  }) {
8
11
  const safeChangedFiles = Array.isArray(changedFiles) ? changedFiles : []
9
12
 
@@ -43,12 +46,20 @@ export function planLaravelDeploymentTasks({
43
46
  const shouldClearCaches = hasPhpChanges
44
47
  const shouldRestartQueues = hasPhpChanges
45
48
 
46
- const steps = [
47
- {
48
- label: `Pull latest changes for ${branch}`,
49
- command: `git pull origin ${branch}`
50
- }
51
- ]
49
+ const steps = []
50
+
51
+ if (maintenanceMode && isLaravel) {
52
+ steps.push({
53
+ label: 'Enable Laravel maintenance mode',
54
+ command: maintenanceDownCommand ?? `${phpCommand} artisan down`,
55
+ kind: 'maintenance-down'
56
+ })
57
+ }
58
+
59
+ steps.push({
60
+ label: `Pull latest changes for ${branch}`,
61
+ command: `git pull origin ${branch}`
62
+ })
52
63
 
53
64
  if (shouldRunComposer) {
54
65
  // Composer is a PHP script, so we need to run it with the correct PHP version
@@ -96,5 +107,13 @@ export function planLaravelDeploymentTasks({
96
107
  })
97
108
  }
98
109
 
110
+ if (maintenanceMode && isLaravel) {
111
+ steps.push({
112
+ label: 'Disable Laravel maintenance mode',
113
+ command: maintenanceUpCommand ?? `${phpCommand} artisan up`,
114
+ kind: 'maintenance-up'
115
+ })
116
+ }
117
+
99
118
  return steps
100
- }
119
+ }
@@ -3,7 +3,7 @@ import process from 'node:process'
3
3
  import {ensureLocalRepositoryState} from '../../deploy/local-repo.mjs'
4
4
  import {bumpLocalPackageVersion} from './bump-local-package-version.mjs'
5
5
  import {resolveLocalDeploymentContext} from './resolve-local-deployment-context.mjs'
6
- import {runLocalDeploymentChecks} from './run-local-deployment-checks.mjs'
6
+ import {resolveLocalDeploymentCheckSupport, runLocalDeploymentChecks} from './run-local-deployment-checks.mjs'
7
7
 
8
8
  export async function prepareLocalDeployment(config, {
9
9
  snapshot = null,
@@ -17,6 +17,11 @@ export async function prepareLocalDeployment(config, {
17
17
  logWarning
18
18
  } = {}) {
19
19
  const context = await resolveLocalDeploymentContext(rootDir)
20
+ const checkSupport = await resolveLocalDeploymentCheckSupport({
21
+ rootDir,
22
+ isLaravel: context.isLaravel,
23
+ runCommandCapture
24
+ })
20
25
 
21
26
  if (!snapshot && context.isLaravel) {
22
27
  await bumpLocalPackageVersion(rootDir, {
@@ -45,7 +50,9 @@ export async function prepareLocalDeployment(config, {
45
50
  runCommandCapture,
46
51
  logProcessing,
47
52
  logSuccess,
48
- logWarning
53
+ logWarning,
54
+ lintCommand: checkSupport.lintCommand,
55
+ testCommand: checkSupport.testCommand
49
56
  })
50
57
 
51
58
  return context
@@ -21,6 +21,52 @@ async function resolveRemoteHome(ssh, sshUser) {
21
21
  return remoteHomeResult.stdout.trim() || `/home/${sshUser}`
22
22
  }
23
23
 
24
+ async function maybeRecoverLaravelMaintenanceMode({
25
+ remotePlan,
26
+ executionState,
27
+ executeRemote,
28
+ runPrompt,
29
+ logWarning
30
+ } = {}) {
31
+ if (!remotePlan?.remoteIsLaravel || !remotePlan?.maintenanceModeEnabled) {
32
+ return
33
+ }
34
+
35
+ if (!executionState?.enteredMaintenanceMode || executionState.exitedMaintenanceMode) {
36
+ return
37
+ }
38
+
39
+ if (typeof runPrompt !== 'function' || typeof executeRemote !== 'function') {
40
+ logWarning?.('Deployment failed while Laravel maintenance mode may still be enabled.')
41
+ return
42
+ }
43
+
44
+ try {
45
+ const answers = await runPrompt([
46
+ {
47
+ type: 'confirm',
48
+ name: 'disableMaintenanceMode',
49
+ message: 'Deployment failed after Laravel maintenance mode was enabled. Run `artisan up` now?',
50
+ default: true
51
+ }
52
+ ])
53
+ const disableMaintenanceMode = answers?.disableMaintenanceMode === true
54
+
55
+ if (!disableMaintenanceMode) {
56
+ logWarning?.('Laravel maintenance mode remains enabled because recovery was not confirmed.')
57
+ return
58
+ }
59
+
60
+ await executeRemote(
61
+ 'Disable Laravel maintenance mode',
62
+ remotePlan.maintenanceUpCommand ?? `${remotePlan.phpCommand} artisan up`
63
+ )
64
+ executionState.exitedMaintenanceMode = true
65
+ } catch (error) {
66
+ logWarning?.(`Failed to disable Laravel maintenance mode after deployment error: ${error.message}`)
67
+ }
68
+ }
69
+
24
70
  export async function runDeployment(config, options = {}) {
25
71
  const {
26
72
  snapshot = null,
@@ -58,6 +104,12 @@ export async function runDeployment(config, options = {}) {
58
104
  const privateKeyPath = await resolveSshKeyPath(config.sshKey)
59
105
  const privateKey = await fs.readFile(privateKeyPath, 'utf8')
60
106
  let remoteCwd = null
107
+ let executeRemote = null
108
+ let remotePlan = null
109
+ const executionState = {
110
+ enteredMaintenanceMode: false,
111
+ exitedMaintenanceMode: false
112
+ }
61
113
 
62
114
  logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
63
115
 
@@ -78,7 +130,7 @@ export async function runDeployment(config, options = {}) {
78
130
  lockAcquired = true
79
131
  logProcessing(`Lock acquired. Running deployment commands in ${remoteCwd}...`)
80
132
 
81
- const executeRemote = createRemoteExecutor({
133
+ executeRemote = createRemoteExecutor({
82
134
  ssh,
83
135
  rootDir,
84
136
  remoteCwd,
@@ -88,7 +140,7 @@ export async function runDeployment(config, options = {}) {
88
140
  logError
89
141
  })
90
142
 
91
- const remotePlan = await buildRemoteDeploymentPlan({
143
+ remotePlan = await buildRemoteDeploymentPlan({
92
144
  config,
93
145
  snapshot,
94
146
  rootDir,
@@ -96,6 +148,7 @@ export async function runDeployment(config, options = {}) {
96
148
  ssh,
97
149
  remoteCwd,
98
150
  executeRemote,
151
+ runPrompt,
99
152
  logProcessing,
100
153
  logSuccess,
101
154
  logWarning
@@ -107,7 +160,8 @@ export async function runDeployment(config, options = {}) {
107
160
  steps: remotePlan.steps,
108
161
  usefulSteps: remotePlan.usefulSteps,
109
162
  pendingSnapshot: remotePlan.pendingSnapshot,
110
- logProcessing
163
+ logProcessing,
164
+ executionState
111
165
  })
112
166
 
113
167
  logSuccess('\nDeployment commands completed successfully.')
@@ -120,6 +174,14 @@ export async function runDeployment(config, options = {}) {
120
174
  logError(`\nTask output has been logged to: ${logPath}`)
121
175
  }
122
176
 
177
+ await maybeRecoverLaravelMaintenanceMode({
178
+ remotePlan,
179
+ executionState,
180
+ executeRemote,
181
+ runPrompt,
182
+ logWarning
183
+ })
184
+
123
185
  if (lockAcquired && ssh && remoteCwd) {
124
186
  try {
125
187
  await compareLocksAndPrompt(rootDir, ssh, remoteCwd, {runPrompt, logWarning})
@@ -144,4 +206,4 @@ export async function runDeployment(config, options = {}) {
144
206
  ssh.dispose()
145
207
  }
146
208
  }
147
- }
209
+ }
@@ -12,20 +12,62 @@ async function hasUncommittedChanges(rootDir, {runCommandCapture} = {}) {
12
12
  })
13
13
  }
14
14
 
15
- async function runLocalLaravelTests(rootDir, {runCommand, logProcessing, logSuccess, logWarning} = {}) {
15
+ function supportsArtisanTestCommand(listOutput = '') {
16
+ return /(?:^|\n)\s*test(?:\s|$)/m.test(listOutput)
17
+ }
18
+
19
+ async function resolveSupportedLaravelTestCommand(rootDir, {runCommandCapture} = {}) {
16
20
  if (!commandExists('php')) {
17
- logWarning?.(
18
- 'PHP is not available in PATH. Skipping local Laravel tests.\n' +
19
- ' To run tests locally, ensure PHP is installed and added to your PATH.\n' +
20
- ' On Windows with Laravel Herd, you may need to add Herd\'s PHP to your system PATH.'
21
+ throw new Error(
22
+ 'Release cannot run because PHP is not available in PATH.\n' +
23
+ 'Zephyr requires `php artisan test --compact` for Laravel projects before deployment.'
24
+ )
25
+ }
26
+
27
+ let artisanCommands
28
+ try {
29
+ artisanCommands = await runCommandCapture('php', ['artisan', 'list'], {cwd: rootDir})
30
+ } catch (error) {
31
+ throw new Error(
32
+ 'Release cannot run because Zephyr could not verify support for `php artisan test`.\n' +
33
+ `Ensure the project can run \`php artisan list\` locally before deployment.\n${error.message}`
34
+ )
35
+ }
36
+
37
+ if (!supportsArtisanTestCommand(artisanCommands)) {
38
+ throw new Error(
39
+ 'Release cannot run because this Laravel project does not support `php artisan test`.\n' +
40
+ 'Zephyr requires Laravel\'s built-in test command before deployment. PHPUnit-only test setups are not supported.'
21
41
  )
22
- return
23
42
  }
24
43
 
44
+ return {
45
+ command: 'php',
46
+ args: ['artisan', 'test', '--compact']
47
+ }
48
+ }
49
+
50
+ export async function resolveLocalDeploymentCheckSupport({
51
+ rootDir,
52
+ isLaravel,
53
+ runCommandCapture
54
+ } = {}) {
55
+ const lintCommand = await preflight.resolveSupportedLintCommand(rootDir, {commandExists})
56
+ const testCommand = isLaravel
57
+ ? await resolveSupportedLaravelTestCommand(rootDir, {runCommandCapture})
58
+ : null
59
+
60
+ return {
61
+ lintCommand,
62
+ testCommand
63
+ }
64
+ }
65
+
66
+ async function runLocalLaravelTests(rootDir, {runCommand, logProcessing, logSuccess, testCommand} = {}) {
25
67
  logProcessing?.('Running Laravel tests locally...')
26
68
 
27
69
  try {
28
- await runCommand('php', ['artisan', 'test', '--compact'], {cwd: rootDir})
70
+ await runCommand(testCommand.command, testCommand.args, {cwd: rootDir})
29
71
  logSuccess?.('Local tests passed.')
30
72
  } catch (error) {
31
73
  if (error.code === 'ENOENT') {
@@ -47,10 +89,22 @@ export async function runLocalDeploymentChecks({
47
89
  runCommandCapture,
48
90
  logProcessing,
49
91
  logSuccess,
50
- logWarning
92
+ logWarning,
93
+ lintCommand = undefined,
94
+ testCommand = undefined
51
95
  } = {}) {
96
+ const support = lintCommand !== undefined || testCommand !== undefined
97
+ ? {lintCommand, testCommand}
98
+ : await resolveLocalDeploymentCheckSupport({
99
+ rootDir,
100
+ isLaravel,
101
+ runCommandCapture
102
+ })
103
+
52
104
  if (hasHook) {
53
- logProcessing?.('Pre-push git hook detected. Skipping local linting and test execution.')
105
+ logProcessing?.(
106
+ '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.'
107
+ )
54
108
  return
55
109
  }
56
110
 
@@ -59,7 +113,8 @@ export async function runLocalDeploymentChecks({
59
113
  logProcessing,
60
114
  logSuccess,
61
115
  logWarning,
62
- commandExists
116
+ commandExists,
117
+ lintCommand: support.lintCommand
63
118
  })
64
119
 
65
120
  if (lintRan) {
@@ -75,6 +130,11 @@ export async function runLocalDeploymentChecks({
75
130
  }
76
131
 
77
132
  if (isLaravel) {
78
- await runLocalLaravelTests(rootDir, {runCommand, logProcessing, logSuccess, logWarning})
133
+ await runLocalLaravelTests(rootDir, {
134
+ runCommand,
135
+ logProcessing,
136
+ logSuccess,
137
+ testCommand: support.testCommand
138
+ })
79
139
  }
80
140
  }
@@ -1,4 +1,5 @@
1
1
  import { getCurrentBranch as getCurrentBranchImpl, getUpstreamRef as getUpstreamRefImpl } from '../utils/git.mjs'
2
+ import {hasPrePushHook} from './preflight.mjs'
2
3
 
3
4
  export async function getCurrentBranch(rootDir) {
4
5
  const branch = await getCurrentBranchImpl(rootDir)
@@ -179,7 +180,22 @@ async function commitAndPushStagedChanges(targetBranch, rootDir, {
179
180
 
180
181
  logProcessing?.('Committing staged changes before deployment...')
181
182
  await runCommand('git', ['commit', '-m', message], { cwd: rootDir })
182
- await runCommand('git', ['push', 'origin', targetBranch], { cwd: rootDir })
183
+
184
+ const prePushHookPresent = await hasPrePushHook(rootDir)
185
+ if (prePushHookPresent) {
186
+ logProcessing?.('Pre-push git hook detected. Running hook during git push...')
187
+ }
188
+
189
+ try {
190
+ await runCommand('git', ['push', 'origin', targetBranch], { cwd: rootDir })
191
+ } catch (error) {
192
+ if (prePushHookPresent) {
193
+ throw new Error(`Git push failed while the pre-push hook was running. See hook output above.\n${error.message}`)
194
+ }
195
+
196
+ throw error
197
+ }
198
+
183
199
  logSuccess?.(`Committed and pushed changes to origin/${targetBranch}.`)
184
200
 
185
201
  const finalStatus = await getGitStatus(rootDir)
@@ -195,14 +211,17 @@ export async function ensureCommittedChangesPushed(targetBranch, rootDir, {
195
211
  logProcessing,
196
212
  logSuccess,
197
213
  logWarning,
198
- getUpstreamRef: getUpstreamRefFn = getUpstreamRef
214
+ getUpstreamRef: getUpstreamRefFn = getUpstreamRef,
215
+ readUpstreamSyncState: readUpstreamSyncStateFn = (branch, dir) =>
216
+ readUpstreamSyncState(branch, dir, {
217
+ runCommand,
218
+ runCommandCapture,
219
+ logWarning,
220
+ getUpstreamRef: getUpstreamRefFn
221
+ }),
222
+ hasPrePushHook: hasPrePushHookFn = hasPrePushHook
199
223
  } = {}) {
200
- const syncState = await readUpstreamSyncState(targetBranch, rootDir, {
201
- runCommand,
202
- runCommandCapture,
203
- logWarning,
204
- getUpstreamRef: getUpstreamRefFn
205
- })
224
+ const syncState = await readUpstreamSyncStateFn(targetBranch, rootDir)
206
225
 
207
226
  const {
208
227
  upstreamRef,
@@ -234,8 +253,31 @@ export async function ensureCommittedChangesPushed(targetBranch, rootDir, {
234
253
 
235
254
  const commitLabel = aheadCount === 1 ? 'commit' : 'commits'
236
255
  logProcessing?.(`Found ${aheadCount} ${commitLabel} not yet pushed to ${upstreamRef}. Pushing before deployment...`)
256
+ const prePushHookPresent = await hasPrePushHookFn(rootDir)
257
+
258
+ if (prePushHookPresent) {
259
+ logProcessing?.('Pre-push git hook detected. Running hook during git push...')
260
+ }
261
+
262
+ try {
263
+ await runCommandCapture('git', ['push', remoteName, `${targetBranch}:${upstreamBranch}`], { cwd: rootDir })
264
+ } catch (error) {
265
+ if (prePushHookPresent) {
266
+ const hookOutput = [error.stdout, error.stderr]
267
+ .map((value) => (typeof value === 'string' ? value.trim() : ''))
268
+ .filter(Boolean)
269
+ .join('\n')
270
+
271
+ throw new Error(
272
+ hookOutput
273
+ ? `Git push failed while the pre-push hook was running.\n${hookOutput}`
274
+ : `Git push failed while the pre-push hook was running.\n${error.message}`
275
+ )
276
+ }
277
+
278
+ throw error
279
+ }
237
280
 
238
- await runCommandCapture('git', ['push', remoteName, `${targetBranch}:${upstreamBranch}`], { cwd: rootDir })
239
281
  logSuccess?.(`Pushed committed changes to ${upstreamRef}.`)
240
282
 
241
283
  return { pushed: true, upstreamRef }
@@ -45,29 +45,67 @@ export async function hasLaravelPint(rootDir) {
45
45
  }
46
46
  }
47
47
 
48
- export async function runLinting(rootDir, { runCommand, logProcessing, logSuccess, logWarning, commandExists } = {}) {
48
+ export async function resolveSupportedLintCommand(rootDir, {commandExists} = {}) {
49
49
  const hasNpmLint = await hasLintScript(rootDir)
50
50
  const hasPint = await hasLaravelPint(rootDir)
51
51
 
52
52
  if (hasNpmLint) {
53
- logProcessing?.('Running npm lint...')
54
- await runCommand('npm', ['run', 'lint'], { cwd: rootDir })
55
- logSuccess?.('Linting completed.')
56
- return true
53
+ if (commandExists && !commandExists('npm')) {
54
+ throw new Error(
55
+ 'Release cannot run because `npm run lint` is configured but npm is not available in PATH.\n' +
56
+ 'Install npm or fix your PATH before releasing.'
57
+ )
58
+ }
59
+
60
+ return {
61
+ type: 'npm',
62
+ command: 'npm',
63
+ args: ['run', 'lint'],
64
+ label: 'npm lint'
65
+ }
57
66
  }
58
67
 
59
68
  if (hasPint) {
60
- // Check if PHP is available before trying to run Pint
61
69
  if (commandExists && !commandExists('php')) {
62
- logWarning?.(
63
- 'PHP is not available in PATH. Skipping Laravel Pint.\n' +
64
- ' To run Pint locally, ensure PHP is installed and added to your PATH.'
70
+ throw new Error(
71
+ 'Release cannot run because Laravel Pint is present but PHP is not available in PATH.\n' +
72
+ 'Zephyr requires `php vendor/bin/pint` to run successfully before deployment.'
65
73
  )
66
- return false
67
74
  }
68
75
 
76
+ return {
77
+ type: 'pint',
78
+ command: 'php',
79
+ args: ['vendor/bin/pint'],
80
+ label: 'Laravel Pint'
81
+ }
82
+ }
83
+
84
+ throw new Error(
85
+ 'Release cannot run because no supported lint command was found.\n' +
86
+ 'Zephyr requires either `npm run lint` or Laravel Pint (`vendor/bin/pint`) before deployment.'
87
+ )
88
+ }
89
+
90
+ export async function runLinting(rootDir, {
91
+ runCommand,
92
+ logProcessing,
93
+ logSuccess,
94
+ commandExists,
95
+ lintCommand = null
96
+ } = {}) {
97
+ const selectedLintCommand = lintCommand ?? await resolveSupportedLintCommand(rootDir, {commandExists})
98
+
99
+ if (selectedLintCommand.type === 'npm') {
100
+ logProcessing?.('Running npm lint...')
101
+ await runCommand(selectedLintCommand.command, selectedLintCommand.args, { cwd: rootDir })
102
+ logSuccess?.('Linting completed.')
103
+ return true
104
+ }
105
+
106
+ if (selectedLintCommand.type === 'pint') {
69
107
  logProcessing?.('Running Laravel Pint...')
70
- await runCommand('php', ['vendor/bin/pint'], { cwd: rootDir })
108
+ await runCommand(selectedLintCommand.command, selectedLintCommand.args, { cwd: rootDir })
71
109
  logSuccess?.('Linting completed.')
72
110
  return true
73
111
  }
@@ -123,4 +161,3 @@ export async function isLocalLaravelProject(rootDir) {
123
161
  return false
124
162
  }
125
163
  }
126
-
package/src/main.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import fs from 'node:fs/promises'
2
+ import {createRequire} from 'node:module'
2
3
  import path from 'node:path'
3
4
  import process from 'node:process'
4
5
 
@@ -16,6 +17,8 @@ import {runDeployment} from './application/deploy/run-deployment.mjs'
16
17
 
17
18
  const RELEASE_SCRIPT_NAME = 'release'
18
19
  const RELEASE_SCRIPT_COMMAND = 'npx @wyxos/zephyr@latest'
20
+ const require = createRequire(import.meta.url)
21
+ const {version: ZEPHYR_VERSION} = require('../package.json')
19
22
 
20
23
  const appContext = createAppContext()
21
24
  const {
@@ -36,6 +39,8 @@ async function runRemoteTasks(config, options = {}) {
36
39
  }
37
40
 
38
41
  async function main(releaseType = null, versionArg = null) {
42
+ logProcessing(`Zephyr v${ZEPHYR_VERSION}`)
43
+
39
44
  if (releaseType === 'node' || releaseType === 'vue') {
40
45
  try {
41
46
  await releaseNode()