@wyxos/zephyr 0.3.2 → 0.3.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.3.2",
3
+ "version": "0.3.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",
@@ -1,6 +1,116 @@
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
+ const LARAVEL_WRITABLE_PATHS = [
7
+ 'bootstrap/cache',
8
+ 'storage/framework/cache',
9
+ 'storage/framework/views',
10
+ 'storage/framework/sessions'
11
+ ]
12
+
13
+ function escapeForSingleQuotes(value) {
14
+ return value.replace(/'/g, "'\\''")
15
+ }
16
+
17
+ function isGroupWritable(mode) {
18
+ if (typeof mode !== 'string' || mode.length < 2) {
19
+ return false
20
+ }
21
+
22
+ const groupDigit = mode.at(-2)
23
+ const parsed = Number.parseInt(groupDigit, 8)
24
+ return Number.isInteger(parsed) && (parsed & 2) === 2
25
+ }
26
+
27
+ function shouldInspectLaravelWritablePaths(steps = []) {
28
+ return steps.some((step) => step.label === 'Clear Laravel caches')
29
+ }
30
+
31
+ async function inspectLaravelWritablePath(ssh, remoteCwd, relativePath) {
32
+ const escapedPath = escapeForSingleQuotes(relativePath)
33
+ const command = [
34
+ `if [ ! -e '${escapedPath}' ]; then`,
35
+ ' printf "__MISSING__";',
36
+ 'else',
37
+ ` WRITABLE="no"; [ -w '${escapedPath}' ] && WRITABLE="yes";`,
38
+ ` OWNER=$(stat -c '%U' '${escapedPath}' 2>/dev/null || printf '?');`,
39
+ ` GROUP=$(stat -c '%G' '${escapedPath}' 2>/dev/null || printf '?');`,
40
+ ` MODE=$(stat -c '%a' '${escapedPath}' 2>/dev/null || printf '?');`,
41
+ ' printf "%s|%s|%s|%s" "$WRITABLE" "$OWNER" "$GROUP" "$MODE";',
42
+ 'fi'
43
+ ].join(' ')
44
+
45
+ const result = await ssh.execCommand(command, {cwd: remoteCwd})
46
+ const output = result.stdout.trim()
47
+
48
+ if (output === '__MISSING__') {
49
+ return {
50
+ path: relativePath,
51
+ exists: false,
52
+ writable: false,
53
+ owner: null,
54
+ group: null,
55
+ mode: null
56
+ }
57
+ }
58
+
59
+ const [writableFlag, owner = '?', group = '?', mode = '?'] = output.split('|')
60
+
61
+ return {
62
+ path: relativePath,
63
+ exists: true,
64
+ writable: writableFlag === 'yes',
65
+ owner,
66
+ group,
67
+ mode
68
+ }
69
+ }
70
+
71
+ async function validateLaravelWritablePaths({
72
+ ssh,
73
+ remoteCwd,
74
+ sshUser,
75
+ steps,
76
+ logProcessing,
77
+ logWarning
78
+ } = {}) {
79
+ if (!shouldInspectLaravelWritablePaths(steps)) {
80
+ return
81
+ }
82
+
83
+ logProcessing?.(`Checking Laravel writable directories for deploy user ${sshUser || 'current SSH user'}...`)
84
+
85
+ const inspections = []
86
+ for (const relativePath of LARAVEL_WRITABLE_PATHS) {
87
+ inspections.push(await inspectLaravelWritablePath(ssh, remoteCwd, relativePath))
88
+ }
89
+
90
+ const blockedPaths = inspections.filter((inspection) => inspection.exists && !inspection.writable)
91
+ if (blockedPaths.length > 0) {
92
+ const details = blockedPaths
93
+ .map((inspection) => ` - ${inspection.path} (owner ${inspection.owner}:${inspection.group}, mode ${inspection.mode})`)
94
+ .join('\n')
95
+
96
+ throw new Error(
97
+ 'Laravel cache-related deployment tasks cannot run because the SSH deploy user cannot write to required directories:\n' +
98
+ `${details}\n` +
99
+ 'Fix permissions before releasing. Typical fix:\n' +
100
+ 'sudo chown -R $USER:www-data bootstrap/cache storage/framework/cache storage/framework/views storage/framework/sessions\n' +
101
+ 'sudo chmod -R ug+rwX bootstrap/cache storage/framework/cache storage/framework/views storage/framework/sessions'
102
+ )
103
+ }
104
+
105
+ const riskyPaths = inspections.filter((inspection) => inspection.exists && inspection.writable && !isGroupWritable(inspection.mode))
106
+ for (const inspection of riskyPaths) {
107
+ logWarning?.(
108
+ `${inspection.path} is writable by the deploy user (${inspection.owner}:${inspection.group}, mode ${inspection.mode}), ` +
109
+ 'but it is not group-writable. Web-created cache files may cause later permission drift.'
110
+ )
111
+ }
112
+ }
113
+
4
114
  async function detectRemoteLaravelProject(ssh, remoteCwd) {
5
115
  const laravelCheck = await ssh.execCommand(
6
116
  'if [ -f artisan ] && [ -f composer.json ] && grep -q "laravel/framework" composer.json; then echo "yes"; else echo "no"; fi',
@@ -99,6 +209,122 @@ async function resolvePhpCommand({
99
209
  }
100
210
  }
101
211
 
212
+ function createMaintenanceModePlan({
213
+ enabled,
214
+ phpCommand,
215
+ usesPrerender = false,
216
+ renderView = null
217
+ } = {}) {
218
+ const maintenanceUpCommand = `${phpCommand} artisan up`
219
+
220
+ if (!enabled) {
221
+ return {
222
+ enabled: false,
223
+ usesPrerender: false,
224
+ renderView: null,
225
+ downCommand: null,
226
+ upCommand: maintenanceUpCommand
227
+ }
228
+ }
229
+
230
+ return {
231
+ enabled: true,
232
+ usesPrerender,
233
+ renderView: usesPrerender ? renderView : null,
234
+ downCommand: usesPrerender && renderView
235
+ ? `${phpCommand} artisan down --render="${renderView}"`
236
+ : `${phpCommand} artisan down`,
237
+ upCommand: maintenanceUpCommand
238
+ }
239
+ }
240
+
241
+ async function resolveMaintenanceMode({
242
+ snapshot,
243
+ remoteIsLaravel,
244
+ runPrompt
245
+ } = {}) {
246
+ if (!remoteIsLaravel) {
247
+ return false
248
+ }
249
+
250
+ if (typeof snapshot?.maintenanceModeEnabled === 'boolean') {
251
+ return snapshot.maintenanceModeEnabled
252
+ }
253
+
254
+ if (typeof runPrompt !== 'function') {
255
+ return false
256
+ }
257
+
258
+ const answers = await runPrompt([
259
+ {
260
+ type: 'confirm',
261
+ name: 'enableMaintenanceMode',
262
+ message: 'Enable Laravel maintenance mode for this deployment? (`artisan down` before deploy, `artisan up` after)',
263
+ default: false
264
+ }
265
+ ])
266
+
267
+ return Boolean(answers?.enableMaintenanceMode)
268
+ }
269
+
270
+ async function resolveMaintenanceModePlan({
271
+ snapshot,
272
+ remoteIsLaravel,
273
+ remoteCwd,
274
+ maintenanceModeEnabled,
275
+ phpCommand,
276
+ ssh,
277
+ executeRemote,
278
+ logProcessing,
279
+ logWarning
280
+ } = {}) {
281
+ if (!remoteIsLaravel || !maintenanceModeEnabled) {
282
+ return createMaintenanceModePlan({enabled: false, phpCommand})
283
+ }
284
+
285
+ if (typeof snapshot?.maintenanceModeUsesPrerender === 'boolean') {
286
+ return createMaintenanceModePlan({
287
+ enabled: true,
288
+ phpCommand,
289
+ usesPrerender: snapshot.maintenanceModeUsesPrerender,
290
+ renderView: snapshot.maintenanceModeRenderView ?? PRERENDERED_MAINTENANCE_VIEW
291
+ })
292
+ }
293
+
294
+ const capabilityResult = await executeRemote(
295
+ 'Inspect Laravel maintenance mode capabilities',
296
+ `${phpCommand} artisan down --help`,
297
+ {printStdout: false, allowFailure: true}
298
+ )
299
+ const helpOutput = `${capabilityResult.stdout ?? ''}\n${capabilityResult.stderr ?? ''}`
300
+ const supportsPrerender = capabilityResult.code === 0 && /(^|\s)--render(?:\[|[=\s]|$)/m.test(helpOutput)
301
+
302
+ if (!supportsPrerender) {
303
+ logProcessing?.('Prerendered Laravel maintenance mode is unavailable on the remote app; using standard maintenance mode.')
304
+ return createMaintenanceModePlan({enabled: true, phpCommand})
305
+ }
306
+
307
+ const maintenanceViewCheck = await ssh.execCommand(
308
+ `if [ -f ${PRERENDERED_MAINTENANCE_FILE} ]; then echo "yes"; else echo "no"; fi`,
309
+ {cwd: remoteCwd}
310
+ )
311
+
312
+ if (maintenanceViewCheck.stdout.trim() !== 'yes') {
313
+ logWarning?.(
314
+ `Laravel supports prerendered maintenance mode, but ${PRERENDERED_MAINTENANCE_FILE} is missing; using standard maintenance mode.`
315
+ )
316
+ return createMaintenanceModePlan({enabled: true, phpCommand})
317
+ }
318
+
319
+ logProcessing?.(`Using prerendered Laravel maintenance response (${PRERENDERED_MAINTENANCE_VIEW}).`)
320
+ return createMaintenanceModePlan({
321
+ enabled: true,
322
+ phpCommand,
323
+ usesPrerender: true,
324
+ renderView: PRERENDERED_MAINTENANCE_VIEW
325
+ })
326
+ }
327
+
102
328
  export async function buildRemoteDeploymentPlan({
103
329
  config,
104
330
  snapshot = null,
@@ -108,7 +334,8 @@ export async function buildRemoteDeploymentPlan({
108
334
  executeRemote,
109
335
  logProcessing,
110
336
  logSuccess,
111
- logWarning
337
+ logWarning,
338
+ runPrompt
112
339
  } = {}) {
113
340
  const remoteIsLaravel = await detectRemoteLaravelProject(ssh, remoteCwd)
114
341
 
@@ -141,12 +368,42 @@ export async function buildRemoteDeploymentPlan({
141
368
  logWarning
142
369
  })
143
370
 
371
+ const maintenanceModeEnabled = await resolveMaintenanceMode({
372
+ snapshot,
373
+ remoteIsLaravel,
374
+ runPrompt
375
+ })
376
+
377
+ const maintenanceModePlan = await resolveMaintenanceModePlan({
378
+ snapshot,
379
+ remoteIsLaravel,
380
+ remoteCwd,
381
+ maintenanceModeEnabled,
382
+ phpCommand,
383
+ ssh,
384
+ executeRemote,
385
+ logProcessing,
386
+ logWarning
387
+ })
388
+
144
389
  const steps = planLaravelDeploymentTasks({
145
390
  branch: config.branch,
146
391
  isLaravel: remoteIsLaravel,
147
392
  changedFiles,
148
393
  horizonConfigured,
149
- phpCommand
394
+ phpCommand,
395
+ maintenanceMode: maintenanceModePlan.enabled,
396
+ maintenanceDownCommand: maintenanceModePlan.downCommand,
397
+ maintenanceUpCommand: maintenanceModePlan.upCommand
398
+ })
399
+
400
+ await validateLaravelWritablePaths({
401
+ ssh,
402
+ remoteCwd,
403
+ sshUser: config.sshUser,
404
+ steps,
405
+ logProcessing,
406
+ logWarning
150
407
  })
151
408
 
152
409
  const usefulSteps = steps.length > 1
@@ -158,6 +415,9 @@ export async function buildRemoteDeploymentPlan({
158
415
  projectPath: config.projectPath,
159
416
  sshUser: config.sshUser,
160
417
  createdAt: new Date().toISOString(),
418
+ maintenanceModeEnabled: maintenanceModePlan.enabled,
419
+ maintenanceModeUsesPrerender: maintenanceModePlan.usesPrerender,
420
+ maintenanceModeRenderView: maintenanceModePlan.renderView,
161
421
  changedFiles,
162
422
  taskLabels: steps.map((step) => step.label)
163
423
  }
@@ -167,6 +427,11 @@ export async function buildRemoteDeploymentPlan({
167
427
  changedFiles,
168
428
  horizonConfigured,
169
429
  phpCommand,
430
+ maintenanceModeEnabled: maintenanceModePlan.enabled,
431
+ maintenanceModeUsesPrerender: maintenanceModePlan.usesPrerender,
432
+ maintenanceModeRenderView: maintenanceModePlan.renderView,
433
+ maintenanceDownCommand: maintenanceModePlan.downCommand,
434
+ maintenanceUpCommand: maintenanceModePlan.upCommand,
170
435
  steps,
171
436
  usefulSteps,
172
437
  pendingSnapshot
@@ -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()