@wyxos/zephyr 0.4.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.4.9",
3
+ "version": "0.5.0",
4
4
  "description": "A streamlined deployment tool for web applications with intelligent Laravel project detection",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -11,6 +11,7 @@ import {
11
11
  runReleaseCommand,
12
12
  validateReleaseDependencies
13
13
  } from '../../release/shared.mjs'
14
+ import {resolveReleaseType} from '../../release/release-type.mjs'
14
15
  import {gitCommitArgs, gitPushArgs, npmVersionArgs} from '../../utils/git-hooks.mjs'
15
16
 
16
17
  async function readPackage(rootDir = process.cwd()) {
@@ -392,12 +393,23 @@ export async function releaseNodePackage({
392
393
  skipGitHooks
393
394
  })
394
395
  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
+ })
395
407
 
396
408
  await runLint(skipLint, pkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
397
409
  await runTests(skipTests, pkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
398
410
  await runLibBuild(skipBuild, pkg, rootDir, {logStep, logSuccess, logWarning, runCommand, skipGitHooks})
399
411
 
400
- const updatedPkg = await bumpVersion(releaseType, rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
412
+ const updatedPkg = await bumpVersion(resolvedReleaseType, rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
401
413
  await runBuild(skipBuild, updatedPkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
402
414
  await pushChanges(rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
403
415
  await deployGHPages(skipDeploy, updatedPkg, rootDir, {logStep, logSuccess, logWarning, runCommand, skipGitHooks})
@@ -11,6 +11,7 @@ import {
11
11
  runReleaseCommand,
12
12
  validateReleaseDependencies
13
13
  } from '../../release/shared.mjs'
14
+ import {resolveReleaseType} from '../../release/release-type.mjs'
14
15
  import {gitCommitArgs, gitPushArgs} from '../../utils/git-hooks.mjs'
15
16
 
16
17
  async function readComposer(rootDir = process.cwd()) {
@@ -259,11 +260,22 @@ export async function releasePackagistPackage({
259
260
  skipGitHooks
260
261
  })
261
262
  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
+ })
262
274
 
263
275
  await runLint(skipLint, rootDir, {logStep, logSuccess, logWarning, runCommand, progressWriter})
264
276
  await runTests(skipTests, composer, rootDir, {logStep, logSuccess, logWarning, runCommand, progressWriter})
265
277
 
266
- const updatedComposer = await bumpVersion(releaseType, rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
278
+ const updatedComposer = await bumpVersion(resolvedReleaseType, rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
267
279
  await pushChanges(rootDir, {logStep, logSuccess, runCommand, skipGitHooks})
268
280
 
269
281
  logSuccess?.(`Release workflow completed for ${composer.name}@${updatedComposer.version}.`)
@@ -0,0 +1,293 @@
1
+ import {mkdtemp, readFile, rm} from 'node:fs/promises'
2
+ import {tmpdir} from 'node:os'
3
+ import path from 'node:path'
4
+ import process from 'node:process'
5
+ import semver from 'semver'
6
+
7
+ import {commandExists} from '../utils/command.mjs'
8
+
9
+ export const RELEASE_TYPES = [
10
+ 'major',
11
+ 'minor',
12
+ 'patch',
13
+ 'premajor',
14
+ 'preminor',
15
+ 'prepatch',
16
+ 'prerelease'
17
+ ]
18
+ const STABLE_RELEASE_TYPES = ['major', 'minor', 'patch']
19
+
20
+ function sanitizeSuggestedReleaseType(message, allowedTypes = RELEASE_TYPES) {
21
+ if (typeof message !== 'string') {
22
+ return null
23
+ }
24
+
25
+ const normalized = message
26
+ .trim()
27
+ .toLowerCase()
28
+ .replace(/^release type:\s*/i, '')
29
+ .split(/\s+/)[0]
30
+ ?.replace(/[^a-z]/g, '')
31
+
32
+ if (!normalized || !allowedTypes.includes(normalized)) {
33
+ return null
34
+ }
35
+
36
+ return normalized
37
+ }
38
+
39
+ function inferReleaseTypeHeuristically({
40
+ currentVersion = '0.0.0',
41
+ commitLog = '',
42
+ diffStat = ''
43
+ } = {}) {
44
+ const combined = `${commitLog}\n${diffStat}`
45
+ const hasBreakingChange = /breaking change|breaking changes|^[a-z]+(?:\(.+\))?!:/im.test(combined)
46
+ const hasFeatureChange = /\bfeat(?:\(.+\))?:/im.test(commitLog) || /\bfeature\b/i.test(combined)
47
+ const hasPrereleaseVersion = Array.isArray(semver.parse(currentVersion)?.prerelease)
48
+ && semver.parse(currentVersion)?.prerelease?.length > 0
49
+
50
+ if (hasPrereleaseVersion) {
51
+ if (hasBreakingChange) {
52
+ return 'premajor'
53
+ }
54
+
55
+ if (hasFeatureChange) {
56
+ return 'preminor'
57
+ }
58
+
59
+ return 'prerelease'
60
+ }
61
+
62
+ if (hasBreakingChange) {
63
+ return 'major'
64
+ }
65
+
66
+ if (hasFeatureChange) {
67
+ return 'minor'
68
+ }
69
+
70
+ return 'patch'
71
+ }
72
+
73
+ function resolveSuggestedReleaseTypeOptions(currentVersion = '0.0.0') {
74
+ const parsedVersion = semver.parse(currentVersion)
75
+
76
+ if (Array.isArray(parsedVersion?.prerelease) && parsedVersion.prerelease.length > 0) {
77
+ return RELEASE_TYPES
78
+ }
79
+
80
+ return STABLE_RELEASE_TYPES
81
+ }
82
+
83
+ function buildChoiceOrder(suggestedReleaseType) {
84
+ return [
85
+ suggestedReleaseType,
86
+ ...RELEASE_TYPES.filter((type) => type !== suggestedReleaseType)
87
+ ]
88
+ }
89
+
90
+ async function readLatestReleaseTag(rootDir, {runCommand} = {}) {
91
+ try {
92
+ const {stdout} = await runCommand('git', ['describe', '--tags', '--abbrev=0'], {
93
+ capture: true,
94
+ cwd: rootDir
95
+ })
96
+
97
+ return stdout.trim() || null
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
103
+ async function readCommitLog(rootDir, {runCommand, latestTag} = {}) {
104
+ const args = latestTag
105
+ ? ['log', '--format=%h %s', `${latestTag}..HEAD`]
106
+ : ['log', '--format=%h %s', '-20']
107
+
108
+ try {
109
+ const {stdout} = await runCommand('git', args, {
110
+ capture: true,
111
+ cwd: rootDir
112
+ })
113
+
114
+ return stdout.trim()
115
+ } catch {
116
+ return ''
117
+ }
118
+ }
119
+
120
+ async function readDiffStat(rootDir, {runCommand, latestTag} = {}) {
121
+ const args = latestTag
122
+ ? ['diff', '--stat', `${latestTag}..HEAD`, '--']
123
+ : ['diff', '--stat', 'HEAD~20..HEAD', '--']
124
+
125
+ try {
126
+ const {stdout} = await runCommand('git', args, {
127
+ capture: true,
128
+ cwd: rootDir
129
+ })
130
+
131
+ return stdout.trim()
132
+ } catch {
133
+ return ''
134
+ }
135
+ }
136
+
137
+ async function buildReleaseSuggestionContext(rootDir, {
138
+ runCommand,
139
+ currentVersion,
140
+ packageName
141
+ } = {}) {
142
+ const latestTag = await readLatestReleaseTag(rootDir, {runCommand})
143
+ const commitLog = await readCommitLog(rootDir, {runCommand, latestTag})
144
+ const diffStat = await readDiffStat(rootDir, {runCommand, latestTag})
145
+
146
+ return {
147
+ currentVersion,
148
+ packageName,
149
+ latestTag,
150
+ commitLog,
151
+ diffStat
152
+ }
153
+ }
154
+
155
+ async function suggestReleaseType(rootDir = process.cwd(), {
156
+ runCommand,
157
+ currentVersion,
158
+ packageName,
159
+ commandExistsImpl = commandExists,
160
+ logStep,
161
+ logWarning
162
+ } = {}) {
163
+ const context = await buildReleaseSuggestionContext(rootDir, {
164
+ runCommand,
165
+ currentVersion,
166
+ packageName
167
+ })
168
+ const allowedSuggestedReleaseTypes = resolveSuggestedReleaseTypeOptions(currentVersion)
169
+ const heuristicReleaseType = inferReleaseTypeHeuristically(context)
170
+
171
+ if (!commandExistsImpl('codex')) {
172
+ return {
173
+ ...context,
174
+ releaseType: heuristicReleaseType,
175
+ source: 'heuristic'
176
+ }
177
+ }
178
+
179
+ let tempDir = null
180
+
181
+ try {
182
+ tempDir = await mkdtemp(path.join(tmpdir(), 'zephyr-release-type-'))
183
+ const outputPath = path.join(tempDir, 'codex-last-message.txt')
184
+
185
+ logStep?.('Evaluating the recommended version bump with Codex...')
186
+
187
+ await runCommand('codex', [
188
+ 'exec',
189
+ '--ephemeral',
190
+ '--model',
191
+ 'gpt-5.4-mini',
192
+ '--sandbox',
193
+ 'read-only',
194
+ '--skip-git-repo-check',
195
+ '--output-last-message',
196
+ outputPath,
197
+ [
198
+ 'Choose exactly one semver release type for the next release.',
199
+ `Reply with exactly one of: ${allowedSuggestedReleaseTypes.join(', ')}.`,
200
+ 'Base the choice on the actual code and workflow changes since the last release tag.',
201
+ 'Prefer stable release types unless the current version already has a prerelease identifier.',
202
+ `Package: ${packageName || 'unknown package'}`,
203
+ `Current version: ${currentVersion || 'unknown'}`,
204
+ `Latest release tag: ${context.latestTag || 'none found'}`,
205
+ 'Commits since the last release:',
206
+ context.commitLog || '- no commits found',
207
+ 'Diff summary since the last release:',
208
+ context.diffStat || '- no diff summary available'
209
+ ].join('\n\n')
210
+ ], {
211
+ capture: true,
212
+ cwd: rootDir
213
+ })
214
+
215
+ const rawSuggestion = await readFile(outputPath, 'utf8')
216
+ const releaseType = sanitizeSuggestedReleaseType(rawSuggestion, allowedSuggestedReleaseTypes)
217
+
218
+ if (!releaseType) {
219
+ return {
220
+ ...context,
221
+ releaseType: heuristicReleaseType,
222
+ source: 'heuristic'
223
+ }
224
+ }
225
+
226
+ return {
227
+ ...context,
228
+ releaseType,
229
+ source: 'codex'
230
+ }
231
+ } catch (error) {
232
+ logWarning?.(`Codex could not suggest a release type: ${error.message}`)
233
+
234
+ return {
235
+ ...context,
236
+ releaseType: heuristicReleaseType,
237
+ source: 'heuristic'
238
+ }
239
+ } finally {
240
+ if (tempDir) {
241
+ await rm(tempDir, {recursive: true, force: true}).catch(() => {})
242
+ }
243
+ }
244
+ }
245
+
246
+ export async function resolveReleaseType({
247
+ releaseType = null,
248
+ currentVersion = '0.0.0',
249
+ packageName = '',
250
+ rootDir = process.cwd(),
251
+ interactive = true,
252
+ runPrompt,
253
+ runCommand,
254
+ logStep,
255
+ logWarning
256
+ } = {}) {
257
+ if (releaseType) {
258
+ return releaseType
259
+ }
260
+
261
+ const suggested = await suggestReleaseType(rootDir, {
262
+ runCommand,
263
+ currentVersion,
264
+ packageName,
265
+ logStep,
266
+ logWarning
267
+ })
268
+ const rangeLabel = suggested.latestTag
269
+ ? `based on changes since ${suggested.latestTag}`
270
+ : 'based on recent changes'
271
+
272
+ if (!interactive || typeof runPrompt !== 'function') {
273
+ logStep?.(`No release type specified. Using suggested ${suggested.releaseType} bump ${rangeLabel}.`)
274
+ return suggested.releaseType
275
+ }
276
+
277
+ const {selectedReleaseType} = await runPrompt([
278
+ {
279
+ type: 'list',
280
+ name: 'selectedReleaseType',
281
+ message:
282
+ `Recommended release bump for ${packageName || 'this package'}@${currentVersion} ` +
283
+ `${rangeLabel}. Choose the version bump to use:`,
284
+ choices: buildChoiceOrder(suggested.releaseType).map((type) => ({
285
+ name: type === suggested.releaseType ? `${type} (recommended)` : type,
286
+ value: type
287
+ })),
288
+ default: suggested.releaseType
289
+ }
290
+ ])
291
+
292
+ return selectedReleaseType
293
+ }
@@ -16,16 +16,9 @@ import {
16
16
  parseWorkingTreeStatus,
17
17
  suggestReleaseCommitMessage
18
18
  } from './commit-message.mjs'
19
+ import {RELEASE_TYPES as SUPPORTED_RELEASE_TYPES} from './release-type.mjs'
19
20
 
20
- const RELEASE_TYPES = new Set([
21
- 'major',
22
- 'minor',
23
- 'patch',
24
- 'premajor',
25
- 'preminor',
26
- 'prepatch',
27
- 'prerelease'
28
- ])
21
+ const RELEASE_TYPES = new Set(SUPPORTED_RELEASE_TYPES)
29
22
  const DIRTY_WORKING_TREE_MESSAGE = 'Working tree has uncommitted changes. Commit or stash them before releasing.'
30
23
  const DIRTY_WORKING_TREE_CANCELLED_MESSAGE = 'Release cancelled: pending changes were not committed.'
31
24
 
@@ -58,9 +51,9 @@ export function parseReleaseArgs({
58
51
 
59
52
  const positionals = filteredArgs.filter((arg) => !arg.startsWith('--'))
60
53
  const presentFlags = new Set(filteredArgs.filter((arg) => arg.startsWith('--')))
61
- const releaseType = positionals[0] ?? 'patch'
54
+ const releaseType = positionals[0] ?? null
62
55
 
63
- if (!RELEASE_TYPES.has(releaseType)) {
56
+ if (releaseType && !RELEASE_TYPES.has(releaseType)) {
64
57
  throw new Error(
65
58
  `Invalid release type "${releaseType}". Use one of: ${Array.from(RELEASE_TYPES).join(', ')}.`
66
59
  )
@@ -17,7 +17,7 @@ function hasExplicitReleaseOptions(options = {}) {
17
17
  export async function releaseNode(options = {}) {
18
18
  const parsed = hasExplicitReleaseOptions(options)
19
19
  ? {
20
- releaseType: options.releaseType ?? 'patch',
20
+ releaseType: 'releaseType' in options ? (options.releaseType ?? null) : null,
21
21
  skipGitHooks: options.skipGitHooks === true,
22
22
  skipTests: options.skipTests === true,
23
23
  skipLint: options.skipLint === true,
@@ -15,7 +15,7 @@ function hasExplicitReleaseOptions(options = {}) {
15
15
  export async function releasePackagist(options = {}) {
16
16
  const parsed = hasExplicitReleaseOptions(options)
17
17
  ? {
18
- releaseType: options.releaseType ?? 'patch',
18
+ releaseType: 'releaseType' in options ? (options.releaseType ?? null) : null,
19
19
  skipGitHooks: options.skipGitHooks === true,
20
20
  skipTests: options.skipTests === true,
21
21
  skipLint: options.skipLint === true