@wyxos/zephyr 0.4.8 → 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.8",
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}.`)
package/src/main.mjs CHANGED
@@ -17,6 +17,7 @@ import {selectDeploymentTarget} from './application/configuration/select-deploym
17
17
  import {resolvePendingSnapshot} from './application/deploy/resolve-pending-snapshot.mjs'
18
18
  import {runDeployment} from './application/deploy/run-deployment.mjs'
19
19
  import {SKIP_GIT_HOOKS_WARNING} from './utils/git-hooks.mjs'
20
+ import {notifyWorkflowResult} from './utils/notifications.mjs'
20
21
 
21
22
  const RELEASE_SCRIPT_NAME = 'release'
22
23
  const RELEASE_SCRIPT_COMMAND = 'npx @wyxos/zephyr@latest'
@@ -99,6 +100,7 @@ async function runRemoteTasks(config, options = {}) {
99
100
 
100
101
  async function main(optionsOrWorkflowType = null, versionArg = null) {
101
102
  const options = normalizeMainOptions(optionsOrWorkflowType, versionArg)
103
+ const rootDir = process.cwd()
102
104
 
103
105
  const executionMode = {
104
106
  interactive: !options.nonInteractive,
@@ -171,6 +173,14 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
171
173
  workflow: currentExecutionMode.workflow
172
174
  }
173
175
  })
176
+ if (!currentExecutionMode.json) {
177
+ await notifyWorkflowResult({
178
+ status: 'success',
179
+ workflow: currentExecutionMode.workflow,
180
+ presetName: currentExecutionMode.presetName,
181
+ rootDir
182
+ })
183
+ }
174
184
  return
175
185
  }
176
186
 
@@ -189,11 +199,17 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
189
199
  workflow: currentExecutionMode.workflow
190
200
  }
191
201
  })
202
+ if (!currentExecutionMode.json) {
203
+ await notifyWorkflowResult({
204
+ status: 'success',
205
+ workflow: currentExecutionMode.workflow,
206
+ presetName: currentExecutionMode.presetName,
207
+ rootDir
208
+ })
209
+ }
192
210
  return
193
211
  }
194
212
 
195
- const rootDir = process.cwd()
196
-
197
213
  await bootstrap.ensureGitignoreEntry(rootDir, {
198
214
  projectConfigDir: PROJECT_CONFIG_DIR,
199
215
  runCommand,
@@ -256,6 +272,14 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
256
272
  workflow: currentExecutionMode.workflow
257
273
  }
258
274
  })
275
+ if (!currentExecutionMode.json) {
276
+ await notifyWorkflowResult({
277
+ status: 'success',
278
+ workflow: currentExecutionMode.workflow,
279
+ presetName: currentExecutionMode.presetName,
280
+ rootDir
281
+ })
282
+ }
259
283
  } catch (error) {
260
284
  const errorCode = getErrorCode(error)
261
285
  emitEvent?.('run_failed', {
@@ -272,6 +296,13 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
272
296
  if (errorCode === 'ZEPHYR_FAILURE' && error.stack) {
273
297
  writeStderrLine(error.stack)
274
298
  }
299
+ await notifyWorkflowResult({
300
+ status: 'failure',
301
+ workflow: currentExecutionMode.workflow,
302
+ presetName: currentExecutionMode.presetName,
303
+ rootDir,
304
+ message: error.message
305
+ })
275
306
  }
276
307
 
277
308
  throw error
@@ -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
@@ -0,0 +1,101 @@
1
+ import path from 'node:path'
2
+ import process from 'node:process'
3
+
4
+ import {commandExists, runCommand as runCommandBase} from './command.mjs'
5
+
6
+ const MAX_NOTIFICATION_MESSAGE_LENGTH = 180
7
+
8
+ function escapeAppleScriptString(value = '') {
9
+ return String(value ?? '')
10
+ .replace(/\\/g, '\\\\')
11
+ .replace(/"/g, '\\"')
12
+ }
13
+
14
+ function humanizeWorkflow(workflow = 'deploy') {
15
+ if (workflow === 'release-node') {
16
+ return 'Node Release'
17
+ }
18
+
19
+ if (workflow === 'release-packagist') {
20
+ return 'Packagist Release'
21
+ }
22
+
23
+ return 'Deploy'
24
+ }
25
+
26
+ function truncateNotificationMessage(message = '') {
27
+ const normalized = String(message ?? '').replace(/\s+/g, ' ').trim()
28
+
29
+ if (normalized.length <= MAX_NOTIFICATION_MESSAGE_LENGTH) {
30
+ return normalized
31
+ }
32
+
33
+ return `${normalized.slice(0, MAX_NOTIFICATION_MESSAGE_LENGTH - 1).trimEnd()}…`
34
+ }
35
+
36
+ function buildNotificationPayload({
37
+ status = 'success',
38
+ workflow = 'deploy',
39
+ presetName = null,
40
+ rootDir = process.cwd(),
41
+ message = ''
42
+ } = {}) {
43
+ const isSuccess = status === 'success'
44
+ const repoName = path.basename(rootDir || process.cwd()) || 'project'
45
+ const title = isSuccess ? '🟢 Zephyr Passed' : '🔴 Zephyr Failed'
46
+ const subtitleParts = [humanizeWorkflow(workflow), repoName]
47
+
48
+ if (presetName) {
49
+ subtitleParts.push(presetName)
50
+ }
51
+
52
+ return {
53
+ title,
54
+ subtitle: subtitleParts.join(' • '),
55
+ message: isSuccess
56
+ ? 'Workflow completed successfully.'
57
+ : truncateNotificationMessage(message || 'Workflow failed.'),
58
+ soundName: isSuccess ? 'Glass' : 'Basso'
59
+ }
60
+ }
61
+
62
+ export async function notifyWorkflowResult({
63
+ status = 'success',
64
+ workflow = 'deploy',
65
+ presetName = null,
66
+ rootDir = process.cwd(),
67
+ message = ''
68
+ } = {}, {
69
+ processRef = process,
70
+ commandExistsImpl = commandExists,
71
+ runCommand = runCommandBase
72
+ } = {}) {
73
+ if (processRef.platform !== 'darwin' || !commandExistsImpl('osascript')) {
74
+ return false
75
+ }
76
+
77
+ const payload = buildNotificationPayload({
78
+ status,
79
+ workflow,
80
+ presetName,
81
+ rootDir,
82
+ message
83
+ })
84
+
85
+ const script = [
86
+ `display notification "${escapeAppleScriptString(payload.message)}"`,
87
+ `with title "${escapeAppleScriptString(payload.title)}"`,
88
+ `subtitle "${escapeAppleScriptString(payload.subtitle)}"`,
89
+ `sound name "${escapeAppleScriptString(payload.soundName)}"`
90
+ ].join(' ')
91
+
92
+ try {
93
+ await runCommand('osascript', ['-e', script], {
94
+ cwd: rootDir,
95
+ stdio: 'ignore'
96
+ })
97
+ return true
98
+ } catch {
99
+ return false
100
+ }
101
+ }