@wyxos/zephyr 0.6.0 → 0.7.5

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
@@ -49,6 +49,12 @@ zephyr minor --skip-checks
49
49
  # Deploy a configured app non-interactively
50
50
  zephyr --non-interactive --preset wyxos-release --maintenance off
51
51
 
52
+ # Deploy a configured app non-interactively and auto-commit dirty changes
53
+ zephyr --non-interactive --preset wyxos-release --auto-commit
54
+
55
+ # Deploy without mutating the local package version
56
+ zephyr --non-interactive --preset wyxos-release --skip-versioning
57
+
52
58
  # Resume a pending non-interactive deployment
53
59
  zephyr --non-interactive --preset wyxos-release --resume-pending --maintenance off
54
60
 
@@ -63,6 +69,10 @@ zephyr --type node minor
63
69
 
64
70
  # Release a Packagist package
65
71
  zephyr --type packagist patch
72
+
73
+ # Release the current package/composer version without bumping version files
74
+ zephyr --type node --skip-versioning
75
+ zephyr --type packagist --skip-versioning
66
76
  ```
67
77
 
68
78
  When `--type node` or `--type vue` is used without a bump argument, Zephyr defaults to `patch`.
@@ -77,7 +87,7 @@ Non-interactive mode is strict and is intended for already-configured projects:
77
87
 
78
88
  - `--non-interactive` fails instead of prompting
79
89
  - app deployments require `--preset <name>`
80
- - Laravel app deployments require `--maintenance on|off` unless resuming a saved snapshot that already contains the choice
90
+ - Laravel app deployments require either a saved preset maintenance preference, `--maintenance on|off`, or a resumable snapshot that already contains the choice
81
91
  - pending deployment snapshots require either `--resume-pending` or `--discard-pending`
82
92
  - stale remote locks are never auto-removed in non-interactive mode
83
93
  - `--json` is only supported together with `--non-interactive`
@@ -94,7 +104,11 @@ If Zephyr would normally prompt to:
94
104
 
95
105
  then non-interactive mode stops immediately with a clear error instead.
96
106
 
97
- For Laravel app deployments, `--maintenance on|off` overrides the maintenance prompt when you want an explicit choice instead of an interactive confirm.
107
+ For Laravel app deployments, `--maintenance on|off` overrides both the saved preset preference and the maintenance prompt when you want an explicit choice for the current run.
108
+
109
+ `--auto-commit` is available for app deployments and tells Zephyr to let local Codex inspect the repo and generate the dirty-tree commit message instead of prompting for one.
110
+
111
+ `--skip-versioning` keeps Zephyr from mutating `package.json` or `composer.json`. On app deploys it skips the local npm version bump step. On package release workflows it releases the version already present in the manifest and creates the release tag from the current `HEAD`.
98
112
 
99
113
  ## AI Agents and Automation
100
114
 
@@ -214,7 +228,15 @@ Deployment targets are stored per-project at `.zephyr/config.json`:
214
228
  {
215
229
  "name": "prod-main",
216
230
  "appId": "app_def456",
217
- "branch": "main"
231
+ "branch": "main",
232
+ "options": {
233
+ "maintenanceMode": true,
234
+ "skipGitHooks": false,
235
+ "skipTests": false,
236
+ "skipLint": false,
237
+ "skipVersioning": false,
238
+ "autoCommit": true
239
+ }
218
240
  }
219
241
  ],
220
242
  "apps": [
@@ -231,6 +253,8 @@ Deployment targets are stored per-project at `.zephyr/config.json`:
231
253
  }
232
254
  ```
233
255
 
256
+ Preset `options` capture repeatable deploy behavior so Zephyr can reuse the same maintenance, dirty-tree, and deploy-check preferences on later runs.
257
+
234
258
  ### Project Directory Structure
235
259
 
236
260
  Zephyr creates a `.zephyr/` directory in your project with:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.6.0",
3
+ "version": "0.7.5",
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",
@@ -23,12 +23,6 @@ export async function selectPreset({
23
23
  const branch = preset.branch || app.branch || 'unknown'
24
24
  displayName = `${preset.name} (${serverName} → ${app.projectPath} [${branch}])`
25
25
  }
26
- } else if (preset.key) {
27
- const keyParts = preset.key.split(':')
28
- const serverName = keyParts[0]
29
- const projectPath = keyParts[1]
30
- const branch = preset.branch || (keyParts.length === 3 ? keyParts[2] : 'unknown')
31
- displayName = `${preset.name} (${serverName} → ${projectPath} [${branch}])`
32
26
  }
33
27
 
34
28
  return {
@@ -1,6 +1,11 @@
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 {
5
+ buildPresetOptionsFromExecutionMode,
6
+ normalizePresetOptions,
7
+ presetOptionsEqual
8
+ } from '../../config/preset-options.mjs'
4
9
  import {ZephyrError} from '../../runtime/errors.mjs'
5
10
 
6
11
  function findPresetByName(projectConfig, presetName) {
@@ -8,6 +13,56 @@ function findPresetByName(projectConfig, presetName) {
8
13
  return presets.find((entry) => entry?.name === presetName) ?? null
9
14
  }
10
15
 
16
+ function createPresetState(rootDir, projectConfig, preset, {
17
+ logSuccess
18
+ } = {}) {
19
+ if (!preset) {
20
+ return null
21
+ }
22
+
23
+ return {
24
+ name: preset.name,
25
+ get options() {
26
+ return normalizePresetOptions(preset.options)
27
+ },
28
+ async saveOptions(nextOptions, {
29
+ message = null
30
+ } = {}) {
31
+ const normalizedOptions = normalizePresetOptions(nextOptions)
32
+
33
+ if (presetOptionsEqual(preset.options, normalizedOptions)) {
34
+ return false
35
+ }
36
+
37
+ preset.options = normalizedOptions
38
+ await saveProjectConfig(rootDir, projectConfig)
39
+
40
+ if (message) {
41
+ logSuccess?.(message)
42
+ }
43
+
44
+ return true
45
+ },
46
+ async applyExecutionMode(executionMode = {}) {
47
+ const nextOptions = buildPresetOptionsFromExecutionMode(executionMode, preset.options)
48
+ return await this.saveOptions(nextOptions)
49
+ }
50
+ }
51
+ }
52
+
53
+ async function promptPresetAutoCommit(runPrompt, enabledByDefault = false) {
54
+ const {autoCommitPreference} = await runPrompt([
55
+ {
56
+ type: 'input',
57
+ name: 'autoCommitPreference',
58
+ message: 'Enable auto-commit for dirty changes on this preset? Leave blank for manual commit prompts.',
59
+ default: enabledByDefault ? 'enabled' : ''
60
+ }
61
+ ])
62
+
63
+ return typeof autoCommitPreference === 'string' && autoCommitPreference.trim().length > 0
64
+ }
65
+
11
66
  function resolvePresetNonInteractive(projectConfig, servers, preset, presetName) {
12
67
  if (!preset) {
13
68
  throw new ZephyrError(
@@ -18,8 +73,8 @@ function resolvePresetNonInteractive(projectConfig, servers, preset, presetName)
18
73
 
19
74
  if (!preset.appId) {
20
75
  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'}
76
+ `Zephyr cannot run non-interactively because preset "${preset.name || presetName}" is invalid.`,
77
+ {code: 'ZEPHYR_PRESET_INVALID'}
23
78
  )
24
79
  }
25
80
 
@@ -54,6 +109,7 @@ export async function selectDeploymentTarget(rootDir, {
54
109
  logSuccess,
55
110
  logWarning,
56
111
  emitEvent,
112
+ promptPresetOptions = true,
57
113
  executionMode = {}
58
114
  } = {}) {
59
115
  const nonInteractive = executionMode?.interactive === false
@@ -72,6 +128,7 @@ export async function selectDeploymentTarget(rootDir, {
72
128
 
73
129
  let server = null
74
130
  let appConfig = null
131
+ let activePreset = null
75
132
  let isCreatingNewPreset = false
76
133
 
77
134
  const preset = nonInteractive
@@ -95,11 +152,11 @@ export async function selectDeploymentTarget(rootDir, {
95
152
  }
96
153
 
97
154
  if (nonInteractive) {
155
+ activePreset = preset
98
156
  const resolved = resolvePresetNonInteractive(projectConfig, servers, preset, executionMode.presetName)
99
157
  server = resolved.server
100
- appConfig = resolved.appConfig
101
158
  appConfig = {
102
- ...appConfig,
159
+ ...resolved.appConfig,
103
160
  branch: resolved.branch
104
161
  }
105
162
  } else if (preset === 'create') {
@@ -107,64 +164,29 @@ export async function selectDeploymentTarget(rootDir, {
107
164
  server = await configurationService.selectServer(servers)
108
165
  appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
109
166
  } else if (preset) {
110
- if (preset.appId) {
111
- appConfig = projectConfig.apps?.find((app) => app.id === preset.appId)
112
-
113
- if (!appConfig) {
114
- logWarning?.('Preset references an application that no longer exists. Creating a new configuration instead.')
115
- await removeInvalidPreset()
116
- server = await configurationService.selectServer(servers)
117
- appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
118
- } else {
119
- server = servers.find((entry) => entry.id === appConfig.serverId || entry.serverName === appConfig.serverName)
120
-
121
- if (!server) {
122
- logWarning?.('Preset references a server that no longer exists. Creating a new configuration instead.')
123
- await removeInvalidPreset()
124
- server = await configurationService.selectServer(servers)
125
- appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
126
- } else if (preset.branch && appConfig.branch !== preset.branch) {
127
- appConfig.branch = preset.branch
128
- await saveProjectConfig(rootDir, projectConfig)
129
- logSuccess?.(`Updated branch to ${preset.branch} from preset.`)
130
- }
131
- }
132
- } else if (preset.key) {
133
- const keyParts = preset.key.split(':')
134
- const serverName = keyParts[0]
135
- const projectPath = keyParts[1]
136
- const presetBranch = preset.branch || (keyParts.length === 3 ? keyParts[2] : null)
167
+ activePreset = preset
168
+ appConfig = projectConfig.apps?.find((app) => app.id === preset.appId)
137
169
 
138
- server = servers.find((entry) => entry.serverName === serverName)
170
+ if (!appConfig) {
171
+ logWarning?.('Preset references an application that no longer exists. Creating a new configuration instead.')
172
+ await removeInvalidPreset()
173
+ activePreset = null
174
+ server = await configurationService.selectServer(servers)
175
+ appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
176
+ } else {
177
+ server = servers.find((entry) => entry.id === appConfig.serverId || entry.serverName === appConfig.serverName)
139
178
 
140
179
  if (!server) {
141
- logWarning?.(`Preset references server "${serverName}" which no longer exists. Creating a new configuration instead.`)
180
+ logWarning?.('Preset references a server that no longer exists. Creating a new configuration instead.')
142
181
  await removeInvalidPreset()
182
+ activePreset = null
143
183
  server = await configurationService.selectServer(servers)
144
184
  appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
145
- } else {
146
- appConfig = projectConfig.apps?.find(
147
- (app) => (app.serverId === server.id || app.serverName === serverName) && app.projectPath === projectPath
148
- )
149
-
150
- if (!appConfig) {
151
- logWarning?.('Preset references an application that no longer exists. Creating a new configuration instead.')
152
- await removeInvalidPreset()
153
- appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
154
- } else {
155
- preset.appId = appConfig.id
156
- if (presetBranch && appConfig.branch !== presetBranch) {
157
- appConfig.branch = presetBranch
158
- }
159
- preset.branch = appConfig.branch
160
- await saveProjectConfig(rootDir, projectConfig)
161
- }
185
+ } else if (preset.branch && appConfig.branch !== preset.branch) {
186
+ appConfig.branch = preset.branch
187
+ await saveProjectConfig(rootDir, projectConfig)
188
+ logSuccess?.(`Updated branch to ${preset.branch} from preset.`)
162
189
  }
163
- } else {
164
- logWarning?.('Preset format is invalid. Creating a new configuration instead.')
165
- await removeInvalidPreset()
166
- server = await configurationService.selectServer(servers)
167
- appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
168
190
  }
169
191
  } else {
170
192
  server = await configurationService.selectServer(servers)
@@ -173,7 +195,7 @@ export async function selectDeploymentTarget(rootDir, {
173
195
 
174
196
  if (nonInteractive && (!appConfig?.sshUser || !appConfig?.sshKey)) {
175
197
  throw new ZephyrError(
176
- `Zephyr cannot run non-interactively because preset "${preset?.name || executionMode.presetName}" is missing SSH details.`,
198
+ `Zephyr cannot run non-interactively because preset "${activePreset?.name || executionMode.presetName}" is missing SSH details.`,
177
199
  {code: 'ZEPHYR_SSH_DETAILS_REQUIRED'}
178
200
  )
179
201
  }
@@ -207,7 +229,7 @@ export async function selectDeploymentTarget(rootDir, {
207
229
  writeStdoutLine(JSON.stringify(deploymentConfig, null, 2))
208
230
  }
209
231
 
210
- if (!nonInteractive && (isCreatingNewPreset || !preset)) {
232
+ if (!nonInteractive && (isCreatingNewPreset || !activePreset)) {
211
233
  const {presetName} = await runPrompt([
212
234
  {
213
235
  type: 'input',
@@ -220,31 +242,48 @@ export async function selectDeploymentTarget(rootDir, {
220
242
  const trimmedName = presetName?.trim()
221
243
 
222
244
  if (trimmedName && trimmedName.length > 0) {
223
- const presets = projectConfig.presets ?? []
224
245
  const appId = appConfig.id
225
246
 
226
247
  if (!appId) {
227
248
  logWarning?.('Cannot save preset: app configuration missing ID.')
228
249
  } else {
229
- const existingIndex = presets.findIndex((entry) => entry.appId === appId)
230
-
231
- if (existingIndex >= 0) {
232
- presets[existingIndex].name = trimmedName
233
- presets[existingIndex].branch = deploymentConfig.branch
234
- } else {
235
- presets.push({
236
- name: trimmedName,
237
- appId,
238
- branch: deploymentConfig.branch
239
- })
250
+ const existingPreset = findPresetByName(projectConfig, trimmedName)
251
+ const nextPreset = existingPreset ?? {
252
+ name: trimmedName,
253
+ appId,
254
+ branch: deploymentConfig.branch,
255
+ options: normalizePresetOptions()
256
+ }
257
+ const nextOptions = buildPresetOptionsFromExecutionMode(executionMode, nextPreset.options)
258
+
259
+ if (promptPresetOptions) {
260
+ nextOptions.autoCommit = await promptPresetAutoCommit(
261
+ runPrompt,
262
+ executionMode.autoCommit === true || existingPreset?.options?.autoCommit === true
263
+ )
264
+ }
265
+
266
+ nextPreset.name = trimmedName
267
+ nextPreset.appId = appId
268
+ nextPreset.branch = deploymentConfig.branch
269
+ nextPreset.options = normalizePresetOptions(nextOptions)
270
+
271
+ if (!existingPreset) {
272
+ projectConfig.presets = [...(projectConfig.presets ?? []), nextPreset]
240
273
  }
241
274
 
242
- projectConfig.presets = presets
243
275
  await saveProjectConfig(rootDir, projectConfig)
244
276
  logSuccess?.(`Saved preset "${trimmedName}" to .zephyr/config.json`)
277
+ activePreset = nextPreset
245
278
  }
246
279
  }
247
280
  }
248
281
 
249
- return {deploymentConfig, projectConfig}
282
+ return {
283
+ deploymentConfig,
284
+ projectConfig,
285
+ presetState: createPresetState(rootDir, projectConfig, activePreset, {
286
+ logSuccess
287
+ })
288
+ }
250
289
  }
@@ -235,6 +235,7 @@ async function resolveMaintenanceMode({
235
235
  snapshot,
236
236
  remoteIsLaravel,
237
237
  runPrompt,
238
+ persistPresetOptions,
238
239
  executionMode = {}
239
240
  } = {}) {
240
241
  if (!remoteIsLaravel) {
@@ -269,7 +270,14 @@ async function resolveMaintenanceMode({
269
270
  }
270
271
  ])
271
272
 
272
- return Boolean(answers?.enableMaintenanceMode)
273
+ const maintenanceModeEnabled = Boolean(answers?.enableMaintenanceMode)
274
+ await persistPresetOptions?.({
275
+ maintenanceMode: maintenanceModeEnabled
276
+ }, {
277
+ message: 'Saved maintenance mode preference to the selected preset.'
278
+ })
279
+
280
+ return maintenanceModeEnabled
273
281
  }
274
282
 
275
283
  async function resolveMaintenanceModePlan({
@@ -333,6 +341,7 @@ async function resolveMaintenanceModePlan({
333
341
  export async function resolveRemoteDeploymentState({
334
342
  snapshot,
335
343
  executionMode = {},
344
+ persistPresetOptions,
336
345
  ssh,
337
346
  remoteCwd,
338
347
  runPrompt,
@@ -351,6 +360,7 @@ export async function resolveRemoteDeploymentState({
351
360
  snapshot,
352
361
  remoteIsLaravel,
353
362
  runPrompt,
363
+ persistPresetOptions,
354
364
  executionMode
355
365
  })
356
366
 
@@ -365,6 +375,7 @@ export async function buildRemoteDeploymentPlan({
365
375
  snapshot = null,
366
376
  requiredPhpVersion = null,
367
377
  executionMode = {},
378
+ persistPresetOptions,
368
379
  remoteIsLaravel = null,
369
380
  maintenanceModeEnabled = null,
370
381
  ssh,
@@ -384,6 +395,7 @@ export async function buildRemoteDeploymentPlan({
384
395
  : await resolveRemoteDeploymentState({
385
396
  snapshot,
386
397
  executionMode,
398
+ persistPresetOptions,
387
399
  ssh,
388
400
  remoteCwd,
389
401
  runPrompt,
@@ -1,3 +1,24 @@
1
+ const FRONTEND_BUILD_EXTENSIONS = [
2
+ '.vue',
3
+ '.css',
4
+ '.scss',
5
+ '.js',
6
+ '.jsx',
7
+ '.mjs',
8
+ '.cjs',
9
+ '.ts',
10
+ '.tsx',
11
+ '.less',
12
+ '.svg',
13
+ '.png',
14
+ '.jpg',
15
+ '.jpeg',
16
+ '.gif',
17
+ '.webp',
18
+ '.avif',
19
+ '.ico'
20
+ ]
21
+
1
22
  export function planLaravelDeploymentTasks({
2
23
  branch,
3
24
  isLaravel,
@@ -39,7 +60,7 @@ export function planLaravelDeploymentTasks({
39
60
  const hasFrontendChanges =
40
61
  isLaravel &&
41
62
  safeChangedFiles.some((file) =>
42
- ['.vue', '.css', '.scss', '.js', '.ts', '.tsx', '.less'].some((ext) => file.endsWith(ext))
63
+ FRONTEND_BUILD_EXTENSIONS.some((ext) => file.endsWith(ext))
43
64
  )
44
65
 
45
66
  const shouldRunBuild = isLaravel && (hasFrontendChanges || shouldRunNpmInstall)
@@ -116,4 +137,4 @@ export function planLaravelDeploymentTasks({
116
137
  }
117
138
 
118
139
  return steps
119
- }
140
+ }
@@ -12,6 +12,8 @@ export async function prepareLocalDeployment(config, {
12
12
  skipGitHooks = false,
13
13
  skipTests = false,
14
14
  skipLint = false,
15
+ skipVersioning = false,
16
+ autoCommit = false,
15
17
  runPrompt,
16
18
  runCommand,
17
19
  runCommandCapture,
@@ -26,7 +28,8 @@ export async function prepareLocalDeployment(config, {
26
28
  logProcessing,
27
29
  logSuccess,
28
30
  logWarning,
29
- skipGitHooks
31
+ skipGitHooks,
32
+ autoCommit
30
33
  })
31
34
 
32
35
  const context = await resolveLocalDeploymentContext(rootDir)
@@ -53,17 +56,22 @@ export async function prepareLocalDeployment(config, {
53
56
  logSuccess,
54
57
  logWarning,
55
58
  lintCommand: checkSupport.lintCommand,
59
+ buildCommand: checkSupport.buildCommand,
56
60
  testCommand: checkSupport.testCommand
57
61
  })
58
62
 
59
- await bumpLocalPackageVersion(rootDir, {
60
- versionArg,
61
- skipGitHooks,
62
- runCommand,
63
- logProcessing,
64
- logSuccess,
65
- logWarning
66
- })
63
+ if (skipVersioning) {
64
+ logWarning?.('Skipping deployment version update because --skip-versioning flag was provided.')
65
+ } else {
66
+ await bumpLocalPackageVersion(rootDir, {
67
+ versionArg,
68
+ skipGitHooks,
69
+ runCommand,
70
+ logProcessing,
71
+ logSuccess,
72
+ logWarning
73
+ })
74
+ }
67
75
 
68
76
  await ensureCommittedChangesPushed(config.branch, rootDir, {
69
77
  runCommand,
@@ -90,6 +98,7 @@ export async function prepareLocalDeployment(config, {
90
98
  logSuccess,
91
99
  logWarning,
92
100
  lintCommand: checkSupport.lintCommand,
101
+ buildCommand: checkSupport.buildCommand,
93
102
  testCommand: checkSupport.testCommand
94
103
  })
95
104
 
@@ -248,7 +248,8 @@ export async function runDeployment(config, options = {}) {
248
248
  snapshot = null,
249
249
  rootDir = process.cwd(),
250
250
  versionArg = null,
251
- context
251
+ context,
252
+ presetState = null
252
253
  } = options
253
254
 
254
255
  const {
@@ -368,6 +369,8 @@ export async function runDeployment(config, options = {}) {
368
369
  skipGitHooks: executionMode?.skipGitHooks === true,
369
370
  skipTests: executionMode?.skipTests === true,
370
371
  skipLint: executionMode?.skipLint === true,
372
+ skipVersioning: executionMode?.skipVersioning === true,
373
+ autoCommit: executionMode?.autoCommit === true,
371
374
  runPrompt,
372
375
  runCommand,
373
376
  runCommandCapture: context.runCommandCapture,
@@ -412,6 +415,7 @@ export async function runDeployment(config, options = {}) {
412
415
  rootDir,
413
416
  requiredPhpVersion,
414
417
  executionMode,
418
+ persistPresetOptions: presetState?.saveOptions,
415
419
  remoteIsLaravel: remoteState?.remoteIsLaravel,
416
420
  maintenanceModeEnabled: remoteState?.maintenanceModeEnabled,
417
421
  ssh,
@@ -66,16 +66,42 @@ export async function resolveLocalDeploymentCheckSupport({
66
66
  }
67
67
  }
68
68
 
69
+ const buildCommand = isLaravel && !skipTests
70
+ ? await preflight.resolveSupportedBuildCommand(rootDir, {commandExists})
71
+ : null
72
+
69
73
  const testCommand = isLaravel && !skipTests
70
74
  ? await resolveSupportedLaravelTestCommand(rootDir, {runCommandCapture})
71
75
  : null
72
76
 
73
77
  return {
74
78
  lintCommand,
79
+ buildCommand,
75
80
  testCommand
76
81
  }
77
82
  }
78
83
 
84
+ async function runLocalLaravelBuild(rootDir, {runCommand, logProcessing, logSuccess, buildCommand} = {}) {
85
+ try {
86
+ await preflight.runBuild(rootDir, {
87
+ runCommand,
88
+ logProcessing,
89
+ logSuccess,
90
+ commandExists,
91
+ buildCommand
92
+ })
93
+ } catch (error) {
94
+ if (error.code === 'ENOENT') {
95
+ throw new Error(
96
+ 'Failed to run local frontend build: npm executable not found.\n' +
97
+ 'Make sure npm is installed and available in your PATH.'
98
+ )
99
+ }
100
+
101
+ throw new Error(`Local frontend build failed. Fix build failures before deploying.\n${error.message}`)
102
+ }
103
+ }
104
+
79
105
  async function runLocalLaravelTests(rootDir, {runCommand, logProcessing, logSuccess, testCommand} = {}) {
80
106
  logProcessing?.('Running Laravel tests locally...')
81
107
 
@@ -108,10 +134,11 @@ export async function runLocalDeploymentChecks({
108
134
  logSuccess,
109
135
  logWarning,
110
136
  lintCommand = undefined,
137
+ buildCommand = undefined,
111
138
  testCommand = undefined
112
139
  } = {}) {
113
- const support = lintCommand !== undefined || testCommand !== undefined
114
- ? {lintCommand, testCommand}
140
+ const support = lintCommand !== undefined || buildCommand !== undefined || testCommand !== undefined
141
+ ? {lintCommand, buildCommand, testCommand}
115
142
  : await resolveLocalDeploymentCheckSupport({
116
143
  rootDir,
117
144
  isLaravel,
@@ -176,6 +203,15 @@ export async function runLocalDeploymentChecks({
176
203
  if (isLaravel && skipTests) {
177
204
  logWarning?.('Skipping tests because --skip-tests flag was provided.')
178
205
  } else if (isLaravel) {
206
+ if (support.buildCommand) {
207
+ await runLocalLaravelBuild(rootDir, {
208
+ runCommand,
209
+ logProcessing,
210
+ logSuccess,
211
+ buildCommand: support.buildCommand
212
+ })
213
+ }
214
+
179
215
  await runLocalLaravelTests(rootDir, {
180
216
  runCommand,
181
217
  logProcessing,