@wyxos/zephyr 0.6.0 → 0.7.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.
@@ -3,6 +3,7 @@ import {readFile} from 'node:fs/promises'
3
3
  import fs from 'node:fs'
4
4
  import path from 'node:path'
5
5
  import process from 'node:process'
6
+ import semver from 'semver'
6
7
 
7
8
  import {writeStderr} from '../../utils/output.mjs'
8
9
  import {
@@ -20,6 +21,44 @@ async function readPackage(rootDir = process.cwd()) {
20
21
  return JSON.parse(raw)
21
22
  }
22
23
 
24
+ function ensureValidPackageVersion(pkg) {
25
+ const version = pkg?.version
26
+
27
+ if (!version) {
28
+ throw new Error('package.json does not have a version field. Add a valid semver version before using --skip-versioning.')
29
+ }
30
+
31
+ if (!semver.valid(version)) {
32
+ throw new Error(`Invalid current version "${version}" in package.json. Must be a valid semver.`)
33
+ }
34
+
35
+ return version
36
+ }
37
+
38
+ async function ensureReleaseTagMissing(version, rootDir = process.cwd(), {
39
+ runCommand = runReleaseCommand
40
+ } = {}) {
41
+ const tagName = `v${version}`
42
+ const {stdout} = await runCommand('git', ['tag', '-l', tagName], {capture: true, cwd: rootDir})
43
+
44
+ if (stdout.trim() === tagName) {
45
+ throw new Error(
46
+ `Release tag ${tagName} already exists. Remove the existing tag or update package.json before using --skip-versioning.`
47
+ )
48
+ }
49
+
50
+ return tagName
51
+ }
52
+
53
+ async function createReleaseTag(version, rootDir = process.cwd(), {
54
+ logStep,
55
+ runCommand = runReleaseCommand
56
+ } = {}) {
57
+ const tagName = `v${version}`
58
+ logStep?.(`Creating release tag ${tagName}...`)
59
+ await runCommand('git', ['tag', '-a', tagName, '-m', tagName], {capture: true, cwd: rootDir})
60
+ }
61
+
23
62
  function hasScript(pkg, scriptName) {
24
63
  return pkg?.scripts?.[scriptName] !== undefined
25
64
  }
@@ -354,6 +393,7 @@ export async function releaseNodePackage({
354
393
  skipGitHooks = false,
355
394
  skipTests = false,
356
395
  skipLint = false,
396
+ skipVersioning = false,
357
397
  skipBuild = false,
358
398
  skipDeploy = false,
359
399
  rootDir = process.cwd(),
@@ -373,6 +413,7 @@ export async function releaseNodePackage({
373
413
 
374
414
  logStep?.('Reading package metadata...')
375
415
  const pkg = await readPackage(rootDir)
416
+ const currentVersion = skipVersioning ? ensureValidPackageVersion(pkg) : null
376
417
 
377
418
  logStep?.('Validating dependencies...')
378
419
  await validateReleaseDependencies(rootDir, {
@@ -393,24 +434,39 @@ export async function releaseNodePackage({
393
434
  skipGitHooks
394
435
  })
395
436
  await ensureReleaseBranchReady({rootDir, branchMethod: 'show-current', logStep, logWarning})
396
- const resolvedReleaseType = await resolveReleaseType({
397
- releaseType,
398
- currentVersion: pkg.version,
399
- packageName: pkg.name,
400
- rootDir,
401
- interactive,
402
- runPrompt,
403
- runCommand,
404
- logStep,
405
- logWarning
406
- })
437
+
438
+ if (skipVersioning) {
439
+ await ensureReleaseTagMissing(currentVersion, rootDir, {runCommand})
440
+ logWarning?.(
441
+ `Skipping package.json version update because --skip-versioning flag was provided. Releasing current version ${currentVersion}.`
442
+ )
443
+ }
444
+
445
+ const resolvedReleaseType = skipVersioning
446
+ ? null
447
+ : await resolveReleaseType({
448
+ releaseType,
449
+ currentVersion: pkg.version,
450
+ packageName: pkg.name,
451
+ rootDir,
452
+ interactive,
453
+ runPrompt,
454
+ runCommand,
455
+ logStep,
456
+ logWarning
457
+ })
407
458
 
408
459
  await runLint(skipLint, pkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
409
460
  await runTests(skipTests, pkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
410
461
  await runLibBuild(skipBuild, pkg, rootDir, {logStep, logSuccess, logWarning, runCommand, skipGitHooks})
411
462
 
412
- const updatedPkg = await bumpVersion(resolvedReleaseType, rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
463
+ const updatedPkg = skipVersioning
464
+ ? {...pkg, version: currentVersion}
465
+ : await bumpVersion(resolvedReleaseType, rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
413
466
  await runBuild(skipBuild, updatedPkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
467
+ if (skipVersioning) {
468
+ await createReleaseTag(updatedPkg.version, rootDir, {logStep, runCommand})
469
+ }
414
470
  await pushChanges(rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
415
471
  await deployGHPages(skipDeploy, updatedPkg, rootDir, {logStep, logSuccess, logWarning, runCommand, skipGitHooks})
416
472
 
@@ -20,6 +20,30 @@ async function readComposer(rootDir = process.cwd()) {
20
20
  return JSON.parse(raw)
21
21
  }
22
22
 
23
+ async function ensureReleaseTagMissing(version, rootDir = process.cwd(), {
24
+ runCommand = runReleaseCommand
25
+ } = {}) {
26
+ const tagName = `v${version}`
27
+ const {stdout} = await runCommand('git', ['tag', '-l', tagName], {capture: true, cwd: rootDir})
28
+
29
+ if (stdout.trim() === tagName) {
30
+ throw new Error(
31
+ `Release tag ${tagName} already exists. Remove the existing tag or update composer.json before using --skip-versioning.`
32
+ )
33
+ }
34
+
35
+ return tagName
36
+ }
37
+
38
+ async function createReleaseTag(version, rootDir = process.cwd(), {
39
+ logStep,
40
+ runCommand = runReleaseCommand
41
+ } = {}) {
42
+ const tagName = `v${version}`
43
+ logStep?.(`Creating git tag ${tagName}...`)
44
+ await runCommand('git', ['tag', tagName], {cwd: rootDir})
45
+ }
46
+
23
47
  async function writeComposer(rootDir, composer, composerPath = null) {
24
48
  const pathToUse = composerPath || join(rootDir, 'composer.json')
25
49
  const content = JSON.stringify(composer, null, 2) + '\n'
@@ -218,6 +242,7 @@ export async function releasePackagistPackage({
218
242
  skipGitHooks = false,
219
243
  skipTests = false,
220
244
  skipLint = false,
245
+ skipVersioning = false,
221
246
  rootDir = process.cwd(),
222
247
  logStep,
223
248
  logSuccess,
@@ -241,6 +266,10 @@ export async function releasePackagistPackage({
241
266
  throw new Error('composer.json does not have a version field. Add "version": "0.0.0" to composer.json.')
242
267
  }
243
268
 
269
+ if (skipVersioning && !semver.valid(composer.version)) {
270
+ throw new Error(`Invalid current version "${composer.version}" in composer.json. Must be a valid semver.`)
271
+ }
272
+
244
273
  logStep?.('Validating dependencies...')
245
274
  await validateReleaseDependencies(rootDir, {
246
275
  prompt: runPrompt,
@@ -260,22 +289,37 @@ export async function releasePackagistPackage({
260
289
  skipGitHooks
261
290
  })
262
291
  await ensureReleaseBranchReady({rootDir, branchMethod: 'show-current', logStep, logWarning})
263
- const resolvedReleaseType = await resolveReleaseType({
264
- releaseType,
265
- currentVersion: composer.version,
266
- packageName: composer.name,
267
- rootDir,
268
- interactive,
269
- runPrompt,
270
- runCommand,
271
- logStep,
272
- logWarning
273
- })
292
+
293
+ if (skipVersioning) {
294
+ await ensureReleaseTagMissing(composer.version, rootDir, {runCommand})
295
+ logWarning?.(
296
+ `Skipping composer.json version update because --skip-versioning flag was provided. Releasing current version ${composer.version}.`
297
+ )
298
+ }
299
+
300
+ const resolvedReleaseType = skipVersioning
301
+ ? null
302
+ : await resolveReleaseType({
303
+ releaseType,
304
+ currentVersion: composer.version,
305
+ packageName: composer.name,
306
+ rootDir,
307
+ interactive,
308
+ runPrompt,
309
+ runCommand,
310
+ logStep,
311
+ logWarning
312
+ })
274
313
 
275
314
  await runLint(skipLint, rootDir, {logStep, logSuccess, logWarning, runCommand, progressWriter})
276
315
  await runTests(skipTests, composer, rootDir, {logStep, logSuccess, logWarning, runCommand, progressWriter})
277
316
 
278
- const updatedComposer = await bumpVersion(resolvedReleaseType, rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
317
+ const updatedComposer = skipVersioning
318
+ ? {...composer}
319
+ : await bumpVersion(resolvedReleaseType, rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
320
+ if (skipVersioning) {
321
+ await createReleaseTag(updatedComposer.version, rootDir, {logStep, runCommand})
322
+ }
279
323
  await pushChanges(rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
280
324
 
281
325
  logSuccess?.(`Release workflow completed for ${composer.name}@${updatedComposer.version}.`)
@@ -5,6 +5,11 @@ import {Command} from 'commander'
5
5
  import {InvalidCliOptionsError} from '../runtime/errors.mjs'
6
6
 
7
7
  const WORKFLOW_TYPES = new Set(['node', 'vue', 'packagist'])
8
+
9
+ function hasFlag(args = [], flag) {
10
+ return args.some((arg) => arg === flag || arg.startsWith(`${flag}=`))
11
+ }
12
+
8
13
  function normalizeMaintenanceMode(value) {
9
14
  if (value == null) {
10
15
  return null
@@ -35,6 +40,8 @@ export function parseCliOptions(args = process.argv.slice(2)) {
35
40
  .option('--resume-pending', 'Resume a saved pending deployment snapshot without prompting.')
36
41
  .option('--discard-pending', 'Discard a saved pending deployment snapshot without prompting.')
37
42
  .option('--maintenance <mode>', 'Laravel maintenance mode policy for app deployments (on|off).')
43
+ .option('--auto-commit', 'Automatically commit dirty deploy changes with a Codex-generated message.')
44
+ .option('--skip-versioning', 'Skip updating package/composer version files before continuing.')
38
45
  .option('--skip-git-hooks', 'Bypass local git hooks for any commits and pushes Zephyr performs.')
39
46
  .option('--skip-checks', 'Skip Zephyr local lint and test execution.')
40
47
  .option('--skip-tests', 'Skip Zephyr local test execution in package release and app deployment workflows.')
@@ -54,6 +61,7 @@ export function parseCliOptions(args = process.argv.slice(2)) {
54
61
 
55
62
  const options = program.opts()
56
63
  const workflowType = options.type ?? null
64
+ const explicitSkipChecks = hasFlag(args, '--skip-checks')
57
65
 
58
66
  if (workflowType && !WORKFLOW_TYPES.has(workflowType)) {
59
67
  throw new InvalidCliOptionsError('Invalid value for --type. Use one of: node, vue, packagist.')
@@ -68,12 +76,21 @@ export function parseCliOptions(args = process.argv.slice(2)) {
68
76
  resumePending: Boolean(options.resumePending),
69
77
  discardPending: Boolean(options.discardPending),
70
78
  maintenanceMode: normalizeMaintenanceMode(options.maintenance),
79
+ autoCommit: Boolean(options.autoCommit),
80
+ skipVersioning: Boolean(options.skipVersioning),
71
81
  skipGitHooks: Boolean(options.skipGitHooks),
72
82
  skipChecks: Boolean(options.skipChecks),
73
83
  skipTests: Boolean(options.skipTests || options.skipChecks),
74
84
  skipLint: Boolean(options.skipLint || options.skipChecks),
75
85
  skipBuild: Boolean(options.skipBuild),
76
- skipDeploy: Boolean(options.skipDeploy)
86
+ skipDeploy: Boolean(options.skipDeploy),
87
+ explicitMaintenanceMode: hasFlag(args, '--maintenance'),
88
+ explicitAutoCommit: hasFlag(args, '--auto-commit'),
89
+ explicitSkipVersioning: hasFlag(args, '--skip-versioning'),
90
+ explicitSkipGitHooks: hasFlag(args, '--skip-git-hooks'),
91
+ explicitSkipChecks,
92
+ explicitSkipTests: hasFlag(args, '--skip-tests') || explicitSkipChecks,
93
+ explicitSkipLint: hasFlag(args, '--skip-lint') || explicitSkipChecks
77
94
  }
78
95
  }
79
96
 
@@ -86,6 +103,8 @@ export function validateCliOptions(options = {}) {
86
103
  resumePending = false,
87
104
  discardPending = false,
88
105
  maintenanceMode = null,
106
+ autoCommit = false,
107
+ skipVersioning = false,
89
108
  skipBuild = false,
90
109
  skipDeploy = false
91
110
  } = options
@@ -112,6 +131,10 @@ export function validateCliOptions(options = {}) {
112
131
  if (maintenanceMode !== null) {
113
132
  throw new InvalidCliOptionsError('--maintenance is only valid for app deployments.')
114
133
  }
134
+
135
+ if (autoCommit) {
136
+ throw new InvalidCliOptionsError('--auto-commit is only valid for app deployments.')
137
+ }
115
138
  } else {
116
139
  if (skipBuild || skipDeploy) {
117
140
  throw new InvalidCliOptionsError('--skip-build and --skip-deploy are only valid for node/vue release workflows.')
@@ -121,4 +144,8 @@ export function validateCliOptions(options = {}) {
121
144
  throw new InvalidCliOptionsError('--non-interactive app deployments require --preset <name>.')
122
145
  }
123
146
  }
147
+
148
+ if (skipVersioning && options.versionArg) {
149
+ throw new InvalidCliOptionsError('--skip-versioning cannot be used together with an explicit version or bump argument.')
150
+ }
124
151
  }
@@ -0,0 +1,89 @@
1
+ export const DEFAULT_PRESET_OPTIONS = Object.freeze({
2
+ maintenanceMode: null,
3
+ skipGitHooks: false,
4
+ skipTests: false,
5
+ skipLint: false,
6
+ skipVersioning: false,
7
+ autoCommit: false
8
+ })
9
+
10
+ function normalizeBoolean(value, fallback = false) {
11
+ return typeof value === 'boolean' ? value : fallback
12
+ }
13
+
14
+ function normalizeMaintenanceMode(value) {
15
+ return typeof value === 'boolean' ? value : null
16
+ }
17
+
18
+ export function normalizePresetOptions(options = {}) {
19
+ return {
20
+ maintenanceMode: normalizeMaintenanceMode(options?.maintenanceMode),
21
+ skipGitHooks: normalizeBoolean(options?.skipGitHooks),
22
+ skipTests: normalizeBoolean(options?.skipTests),
23
+ skipLint: normalizeBoolean(options?.skipLint),
24
+ skipVersioning: normalizeBoolean(options?.skipVersioning),
25
+ autoCommit: normalizeBoolean(options?.autoCommit)
26
+ }
27
+ }
28
+
29
+ export function mergeDeployOptions(executionMode = {}, presetOptions = {}) {
30
+ const normalizedPresetOptions = normalizePresetOptions(presetOptions)
31
+
32
+ return {
33
+ maintenanceMode: executionMode.explicitMaintenanceMode === true
34
+ ? executionMode.maintenanceMode
35
+ : normalizedPresetOptions.maintenanceMode,
36
+ skipGitHooks: executionMode.explicitSkipGitHooks === true
37
+ ? executionMode.skipGitHooks === true
38
+ : normalizedPresetOptions.skipGitHooks,
39
+ skipTests: executionMode.explicitSkipTests === true
40
+ ? executionMode.skipTests === true
41
+ : normalizedPresetOptions.skipTests,
42
+ skipLint: executionMode.explicitSkipLint === true
43
+ ? executionMode.skipLint === true
44
+ : normalizedPresetOptions.skipLint,
45
+ skipVersioning: executionMode.explicitSkipVersioning === true
46
+ ? executionMode.skipVersioning === true
47
+ : normalizedPresetOptions.skipVersioning,
48
+ autoCommit: executionMode.explicitAutoCommit === true
49
+ ? executionMode.autoCommit === true
50
+ : normalizedPresetOptions.autoCommit
51
+ }
52
+ }
53
+
54
+ export function buildPresetOptionsFromExecutionMode(executionMode = {}, existingOptions = {}) {
55
+ const normalizedOptions = normalizePresetOptions(existingOptions)
56
+
57
+ if (executionMode.explicitMaintenanceMode === true) {
58
+ normalizedOptions.maintenanceMode = executionMode.maintenanceMode
59
+ }
60
+
61
+ if (executionMode.explicitSkipGitHooks === true) {
62
+ normalizedOptions.skipGitHooks = executionMode.skipGitHooks === true
63
+ }
64
+
65
+ if (executionMode.explicitSkipTests === true) {
66
+ normalizedOptions.skipTests = executionMode.skipTests === true
67
+ }
68
+
69
+ if (executionMode.explicitSkipLint === true) {
70
+ normalizedOptions.skipLint = executionMode.skipLint === true
71
+ }
72
+
73
+ if (executionMode.explicitSkipVersioning === true) {
74
+ normalizedOptions.skipVersioning = executionMode.skipVersioning === true
75
+ }
76
+
77
+ if (executionMode.explicitAutoCommit === true) {
78
+ normalizedOptions.autoCommit = executionMode.autoCommit === true
79
+ }
80
+
81
+ return normalizedOptions
82
+ }
83
+
84
+ export function presetOptionsEqual(left = {}, right = {}) {
85
+ const normalizedLeft = normalizePresetOptions(left)
86
+ const normalizedRight = normalizePresetOptions(right)
87
+
88
+ return JSON.stringify(normalizedLeft) === JSON.stringify(normalizedRight)
89
+ }
@@ -1,6 +1,10 @@
1
1
  import fs from 'node:fs/promises'
2
2
 
3
3
  import {ZephyrError} from '../runtime/errors.mjs'
4
+ import {
5
+ normalizePresetOptions,
6
+ presetOptionsEqual
7
+ } from './preset-options.mjs'
4
8
  import { ensureDirectory, getProjectConfigDir, getProjectConfigPath } from '../utils/paths.mjs'
5
9
  import { generateId } from '../utils/id.mjs'
6
10
 
@@ -44,27 +48,58 @@ export function migratePresets(presets, apps) {
44
48
  return { presets: [], needsMigration: false }
45
49
  }
46
50
 
47
- const keyToAppId = new Map()
51
+ const appLookup = new Map()
48
52
  apps.forEach((app) => {
49
53
  if (app.id && app.serverName && app.projectPath) {
50
54
  const key = `${app.serverName}:${app.projectPath}`
51
- keyToAppId.set(key, app.id)
55
+ appLookup.set(key, app)
52
56
  }
53
57
  })
54
58
 
55
59
  let needsMigration = false
56
- const migrated = presets.map((preset) => {
57
- const updated = { ...preset }
60
+ const migrated = presets.flatMap((preset) => {
61
+ if (!preset || typeof preset !== 'object') {
62
+ needsMigration = true
63
+ return []
64
+ }
58
65
 
59
- if (preset.key && !preset.appId) {
60
- const appId = keyToAppId.get(preset.key)
61
- if (appId) {
62
- needsMigration = true
63
- updated.appId = appId
66
+ const updated = {
67
+ name: typeof preset.name === 'string' ? preset.name : '',
68
+ appId: typeof preset.appId === 'string' ? preset.appId : null,
69
+ branch: typeof preset.branch === 'string' ? preset.branch : null,
70
+ options: normalizePresetOptions(preset.options)
71
+ }
72
+
73
+ if (!presetOptionsEqual(updated.options, preset.options)) {
74
+ needsMigration = true
75
+ }
76
+
77
+ if (preset.key) {
78
+ needsMigration = true
79
+ const [serverName = null, projectPath = null, legacyBranch = null] = String(preset.key).split(':')
80
+ const app = serverName && projectPath
81
+ ? appLookup.get(`${serverName}:${projectPath}`)
82
+ : null
83
+
84
+ if (app?.id) {
85
+ updated.appId = app.id
86
+ }
87
+
88
+ if (!updated.branch && legacyBranch) {
89
+ updated.branch = legacyBranch
64
90
  }
65
91
  }
66
92
 
67
- return updated
93
+ if (!updated.name) {
94
+ needsMigration = true
95
+ return []
96
+ }
97
+
98
+ if (!preset.appId || preset.key || preset.branch !== updated.branch) {
99
+ needsMigration = true
100
+ }
101
+
102
+ return [updated]
68
103
  })
69
104
 
70
105
  return { presets: migrated, needsMigration }
@@ -177,6 +177,7 @@ async function commitAndPushPendingChanges(targetBranch, rootDir, {
177
177
  logProcessing,
178
178
  logSuccess,
179
179
  logWarning,
180
+ autoCommit = false,
180
181
  skipGitHooks = false,
181
182
  suggestCommitMessage = suggestCommitMessageImpl
182
183
  } = {}) {
@@ -186,7 +187,7 @@ async function commitAndPushPendingChanges(targetBranch, rootDir, {
186
187
  return
187
188
  }
188
189
 
189
- if (typeof runPrompt !== 'function') {
190
+ if (!autoCommit && typeof runPrompt !== 'function') {
190
191
  throw new Error(DIRTY_DEPLOYMENT_MESSAGE)
191
192
  }
192
193
 
@@ -213,24 +214,34 @@ async function commitAndPushPendingChanges(targetBranch, rootDir, {
213
214
  logWarning,
214
215
  statusEntries
215
216
  })
216
- const { commitMessage } = await runPrompt([
217
- {
218
- type: 'input',
219
- name: 'commitMessage',
220
- message:
221
- 'Pending changes detected before deployment:\n\n' +
222
- `${formatWorkingTreePreview(statusEntries)}\n\n` +
223
- 'Enter a commit message to stage and commit all current changes before continuing.\n' +
224
- 'Leave blank to cancel.',
225
- default: suggestedCommitMessage ?? ''
217
+ let message = suggestedCommitMessage?.trim() ?? ''
218
+
219
+ if (autoCommit) {
220
+ if (!message) {
221
+ throw new Error('Deployment auto-commit failed because Codex could not determine a usable commit message.')
226
222
  }
227
- ])
228
223
 
229
- if (!commitMessage || commitMessage.trim().length === 0) {
230
- throw new Error(DIRTY_DEPLOYMENT_CANCELLED_MESSAGE)
231
- }
224
+ logProcessing?.(`Auto-commit enabled. Using Codex-generated commit message "${message}".`)
225
+ } else {
226
+ const { commitMessage } = await runPrompt([
227
+ {
228
+ type: 'input',
229
+ name: 'commitMessage',
230
+ message:
231
+ 'Pending changes detected before deployment:\n\n' +
232
+ `${formatWorkingTreePreview(statusEntries)}\n\n` +
233
+ 'Enter a commit message to stage and commit all current changes before continuing.\n' +
234
+ 'Leave blank to cancel.',
235
+ default: message
236
+ }
237
+ ])
232
238
 
233
- const message = commitMessage.trim()
239
+ if (!commitMessage || commitMessage.trim().length === 0) {
240
+ throw new Error(DIRTY_DEPLOYMENT_CANCELLED_MESSAGE)
241
+ }
242
+
243
+ message = commitMessage.trim()
244
+ }
234
245
 
235
246
  logProcessing?.('Staging all pending changes before deployment...')
236
247
  await runCommand('git', ['add', '-A'], { cwd: rootDir })
@@ -357,6 +368,7 @@ export async function ensureLocalRepositoryState(targetBranch, rootDir = process
357
368
  logProcessing,
358
369
  logSuccess,
359
370
  logWarning,
371
+ autoCommit = false,
360
372
  skipGitHooks = false,
361
373
  suggestCommitMessage: suggestCommitMessageFn = suggestCommitMessageImpl,
362
374
  getCurrentBranch: getCurrentBranchFn = getCurrentBranch,
@@ -436,6 +448,7 @@ export async function ensureLocalRepositoryState(targetBranch, rootDir = process
436
448
  logProcessing,
437
449
  logSuccess,
438
450
  logWarning,
451
+ autoCommit,
439
452
  skipGitHooks,
440
453
  suggestCommitMessage: suggestCommitMessageFn
441
454
  })
@@ -25,17 +25,30 @@ export async function hasPrePushHook(rootDir) {
25
25
  return false
26
26
  }
27
27
 
28
+ async function readPackageJson(rootDir) {
29
+ const packageJsonPath = path.join(rootDir, 'package.json')
30
+ const raw = await fs.readFile(packageJsonPath, 'utf8')
31
+ return JSON.parse(raw)
32
+ }
33
+
28
34
  export async function hasLintScript(rootDir) {
29
35
  try {
30
- const packageJsonPath = path.join(rootDir, 'package.json')
31
- const raw = await fs.readFile(packageJsonPath, 'utf8')
32
- const packageJson = JSON.parse(raw)
36
+ const packageJson = await readPackageJson(rootDir)
33
37
  return packageJson.scripts && typeof packageJson.scripts.lint === 'string'
34
38
  } catch {
35
39
  return false
36
40
  }
37
41
  }
38
42
 
43
+ export async function hasBuildScript(rootDir) {
44
+ try {
45
+ const packageJson = await readPackageJson(rootDir)
46
+ return packageJson.scripts && typeof packageJson.scripts.build === 'string'
47
+ } catch {
48
+ return false
49
+ }
50
+ }
51
+
39
52
  export async function hasLaravelPint(rootDir) {
40
53
  try {
41
54
  const pintPath = path.join(rootDir, 'vendor', 'bin', 'pint')
@@ -91,6 +104,28 @@ export async function resolveSupportedLintCommand(rootDir, {commandExists} = {})
91
104
  throw error
92
105
  }
93
106
 
107
+ export async function resolveSupportedBuildCommand(rootDir, {commandExists} = {}) {
108
+ const hasNpmBuild = await hasBuildScript(rootDir)
109
+
110
+ if (!hasNpmBuild) {
111
+ return null
112
+ }
113
+
114
+ if (commandExists && !commandExists('npm')) {
115
+ throw new Error(
116
+ 'Release cannot run because `npm run build` is configured but npm is not available in PATH.\n' +
117
+ 'Install npm or fix your PATH before deploying.'
118
+ )
119
+ }
120
+
121
+ return {
122
+ type: 'npm',
123
+ command: 'npm',
124
+ args: ['run', 'build'],
125
+ label: 'npm build'
126
+ }
127
+ }
128
+
94
129
  export async function runLinting(rootDir, {
95
130
  runCommand,
96
131
  logProcessing,
@@ -117,6 +152,25 @@ export async function runLinting(rootDir, {
117
152
  return false
118
153
  }
119
154
 
155
+ export async function runBuild(rootDir, {
156
+ runCommand,
157
+ logProcessing,
158
+ logSuccess,
159
+ commandExists,
160
+ buildCommand = null
161
+ } = {}) {
162
+ const selectedBuildCommand = buildCommand ?? await resolveSupportedBuildCommand(rootDir, {commandExists})
163
+
164
+ if (selectedBuildCommand === null) {
165
+ return false
166
+ }
167
+
168
+ logProcessing?.('Running local frontend build...')
169
+ await runCommand(selectedBuildCommand.command, selectedBuildCommand.args, {cwd: rootDir})
170
+ logSuccess?.('Local frontend build completed.')
171
+ return true
172
+ }
173
+
120
174
  export function hasStagedChanges(statusOutput) {
121
175
  if (!statusOutput || statusOutput.length === 0) {
122
176
  return false