@wyxos/zephyr 0.4.7 → 0.4.9

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.7",
3
+ "version": "0.4.9",
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,6 @@
1
1
  import process from 'node:process'
2
2
 
3
- import {ensureLocalRepositoryState} from '../../deploy/local-repo.mjs'
3
+ import {ensureCommittedChangesPushed, 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
6
  import {resolveLocalDeploymentCheckSupport, runLocalDeploymentChecks} from './run-local-deployment-checks.mjs'
@@ -17,6 +17,16 @@ export async function prepareLocalDeployment(config, {
17
17
  logSuccess,
18
18
  logWarning
19
19
  } = {}) {
20
+ await ensureLocalRepositoryState(config.branch, rootDir, {
21
+ runPrompt,
22
+ runCommand,
23
+ runCommandCapture,
24
+ logProcessing,
25
+ logSuccess,
26
+ logWarning,
27
+ skipGitHooks
28
+ })
29
+
20
30
  const context = await resolveLocalDeploymentContext(rootDir)
21
31
  const checkSupport = await resolveLocalDeploymentCheckSupport({
22
32
  rootDir,
@@ -33,17 +43,16 @@ export async function prepareLocalDeployment(config, {
33
43
  logSuccess,
34
44
  logWarning
35
45
  })
36
- }
37
46
 
38
- await ensureLocalRepositoryState(config.branch, rootDir, {
39
- runPrompt,
40
- runCommand,
41
- runCommandCapture,
42
- logProcessing,
43
- logSuccess,
44
- logWarning,
45
- skipGitHooks
46
- })
47
+ await ensureCommittedChangesPushed(config.branch, rootDir, {
48
+ runCommand,
49
+ runCommandCapture,
50
+ logProcessing,
51
+ logSuccess,
52
+ logWarning,
53
+ skipGitHooks
54
+ })
55
+ }
47
56
 
48
57
  await runLocalDeploymentChecks({
49
58
  rootDir,
@@ -1,6 +1,15 @@
1
1
  import { getCurrentBranch as getCurrentBranchImpl, getUpstreamRef as getUpstreamRefImpl } from '../utils/git.mjs'
2
2
  import {hasPrePushHook} from './preflight.mjs'
3
3
  import {gitCommitArgs, gitPushArgs} from '../utils/git-hooks.mjs'
4
+ import {
5
+ buildFallbackCommitMessage,
6
+ formatWorkingTreePreview,
7
+ parseWorkingTreeEntries,
8
+ suggestCommitMessage as suggestCommitMessageImpl
9
+ } from '../release/commit-message.mjs'
10
+
11
+ const DIRTY_DEPLOYMENT_MESSAGE = 'Local repository has uncommitted changes. Commit or stash them before deployment.'
12
+ const DIRTY_DEPLOYMENT_CANCELLED_MESSAGE = 'Deployment cancelled: pending changes were not committed.'
4
13
 
5
14
  export async function getCurrentBranch(rootDir) {
6
15
  const branch = await getCurrentBranchImpl(rootDir)
@@ -161,27 +170,84 @@ async function checkoutTargetBranch(targetBranch, currentBranch, rootDir, {
161
170
  logSuccess?.(`Checked out ${targetBranch} locally.`)
162
171
  }
163
172
 
164
- async function commitAndPushStagedChanges(targetBranch, rootDir, {
173
+ async function commitAndPushPendingChanges(targetBranch, rootDir, {
165
174
  runPrompt,
166
175
  runCommand,
176
+ runCommandCapture,
167
177
  getGitStatus,
168
178
  logProcessing,
169
179
  logSuccess,
170
180
  logWarning,
171
- skipGitHooks = false
181
+ skipGitHooks = false,
182
+ suggestCommitMessage = suggestCommitMessageImpl
172
183
  } = {}) {
184
+ const statusEntries = parseWorkingTreeEntries(await getGitStatus(rootDir))
185
+
186
+ if (statusEntries.length === 0) {
187
+ return
188
+ }
189
+
190
+ if (typeof runPrompt !== 'function') {
191
+ throw new Error(DIRTY_DEPLOYMENT_MESSAGE)
192
+ }
193
+
194
+ const captureAwareRunCommand = async (command, args, { capture = false, cwd } = {}) => {
195
+ if (capture) {
196
+ const captured = await runCommandCapture(command, args, { cwd })
197
+
198
+ if (typeof captured === 'string') {
199
+ return {stdout: captured.trim(), stderr: ''}
200
+ }
201
+
202
+ const stdout = captured?.stdout ?? ''
203
+ const stderr = captured?.stderr ?? ''
204
+ return {stdout: stdout.trim(), stderr: stderr.trim()}
205
+ }
206
+
207
+ await runCommand(command, args, { cwd })
208
+ return undefined
209
+ }
210
+
211
+ const suggestedCommitMessage = await suggestCommitMessage(rootDir, {
212
+ runCommand: captureAwareRunCommand,
213
+ logStep: logProcessing,
214
+ logWarning,
215
+ statusEntries
216
+ }) ?? buildFallbackCommitMessage(statusEntries)
217
+
218
+ const changeLabel = statusEntries.length === 1 ? 'change' : 'changes'
219
+ const {shouldCommitPendingChanges} = await runPrompt([
220
+ {
221
+ type: 'confirm',
222
+ name: 'shouldCommitPendingChanges',
223
+ message:
224
+ `Pending ${changeLabel} detected before deployment:\n\n` +
225
+ `${formatWorkingTreePreview(statusEntries)}\n\n` +
226
+ 'Stage and commit all current changes before continuing?',
227
+ default: true
228
+ }
229
+ ])
230
+
231
+ if (!shouldCommitPendingChanges) {
232
+ throw new Error(DIRTY_DEPLOYMENT_CANCELLED_MESSAGE)
233
+ }
234
+
173
235
  const { commitMessage } = await runPrompt([
174
236
  {
175
237
  type: 'input',
176
238
  name: 'commitMessage',
177
- message: 'Enter a commit message for pending changes before deployment',
239
+ message: 'Commit message for pending deployment changes',
240
+ default: suggestedCommitMessage,
178
241
  validate: (value) => (value && value.trim().length > 0 ? true : 'Commit message cannot be empty.')
179
242
  }
180
243
  ])
181
244
 
182
245
  const message = commitMessage.trim()
183
246
 
184
- logProcessing?.('Committing staged changes before deployment...')
247
+ logProcessing?.('Staging all pending changes before deployment...')
248
+ await runCommand('git', ['add', '-A'], { cwd: rootDir })
249
+
250
+ logProcessing?.('Committing pending changes before deployment...')
185
251
  await runCommand('git', gitCommitArgs(['-m', message], {skipGitHooks}), { cwd: rootDir })
186
252
 
187
253
  const prePushHookPresent = await hasPrePushHook(rootDir)
@@ -203,7 +269,8 @@ async function commitAndPushStagedChanges(targetBranch, rootDir, {
203
269
  throw error
204
270
  }
205
271
 
206
- logSuccess?.(`Committed and pushed changes to origin/${targetBranch}.`)
272
+ logSuccess?.(`Committed pending changes with "${message}".`)
273
+ logSuccess?.(`Pushed committed changes to origin/${targetBranch}.`)
207
274
 
208
275
  const finalStatus = await getGitStatus(rootDir)
209
276
 
@@ -303,6 +370,7 @@ export async function ensureLocalRepositoryState(targetBranch, rootDir = process
303
370
  logSuccess,
304
371
  logWarning,
305
372
  skipGitHooks = false,
373
+ suggestCommitMessage: suggestCommitMessageFn = suggestCommitMessageImpl,
306
374
  getCurrentBranch: getCurrentBranchFn = getCurrentBranch,
307
375
  getGitStatus: getGitStatusFn = (dir) => getGitStatus(dir, { runCommandCapture }),
308
376
  readUpstreamSyncState: readUpstreamSyncStateFn = (branch, dir) =>
@@ -371,21 +439,17 @@ export async function ensureLocalRepositoryState(targetBranch, rootDir = process
371
439
  return
372
440
  }
373
441
 
374
- if (!hasStagedChanges(statusAfterCheckout)) {
375
- await ensureCommittedChangesPushedFn(targetBranch, rootDir)
376
- logProcessing?.('No staged changes detected. Unstaged or untracked files will not affect deployment. Proceeding with deployment.')
377
- return
378
- }
379
-
380
- logWarning?.(`Staged changes detected on ${targetBranch}. A commit is required before deployment.`)
381
- await commitAndPushStagedChanges(targetBranch, rootDir, {
442
+ logWarning?.(`Pending changes detected on ${targetBranch}. A commit is required before deployment.`)
443
+ await commitAndPushPendingChanges(targetBranch, rootDir, {
382
444
  runPrompt,
383
445
  runCommand,
446
+ runCommandCapture,
384
447
  getGitStatus: getGitStatusFn,
385
448
  logProcessing,
386
449
  logSuccess,
387
450
  logWarning,
388
- skipGitHooks
451
+ skipGitHooks,
452
+ suggestCommitMessage: suggestCommitMessageFn
389
453
  })
390
454
 
391
455
  await ensureCommittedChangesPushedFn(targetBranch, rootDir)
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
@@ -9,6 +9,11 @@ const CONVENTIONAL_COMMIT_PATTERN = /^(build|chore|ci|docs|feat|fix|perf|refacto
9
9
  const GENERIC_SUBJECT_PATTERNS = [
10
10
  /^commit pending (release )?changes$/i,
11
11
  /^pending (release )?changes$/i,
12
+ /^commit pending changes before .+$/i,
13
+ /^commit pending (release |deployment )?changes before .+$/i,
14
+ /^commit (all )?(current |pending )?changes( before .+)?$/i,
15
+ /^stage and commit (all )?(current |pending )?changes( before .+)?$/i,
16
+ /^(allow|enable|support) committing pending changes( before .+)?$/i,
12
17
  /^commit changes$/i,
13
18
  /^update changes$/i,
14
19
  /^update files$/i,
@@ -43,6 +48,11 @@ const TOPIC_STOP_WORDS = new Set([
43
48
  'shared',
44
49
  'index',
45
50
  'main',
51
+ 'local',
52
+ 'repo',
53
+ 'prepare',
54
+ 'commit',
55
+ 'message',
46
56
  'js',
47
57
  'jsx',
48
58
  'ts',
@@ -58,6 +68,24 @@ const TOPIC_STOP_WORDS = new Set([
58
68
  'lock'
59
69
  ])
60
70
 
71
+ function buildTargetedFallbackCommitMessage(statusEntries = []) {
72
+ const paths = statusEntries.map((entry) => entry.path.toLowerCase())
73
+ const touchesDeployPrep = paths.some((pathValue) => pathValue.includes('prepare-local-deployment'))
74
+ const touchesLocalRepo = paths.some((pathValue) => pathValue.includes('local-repo'))
75
+ const touchesCommitMessage = paths.some((pathValue) => pathValue.includes('commit-message'))
76
+ const touchesReleaseFlow = paths.some((pathValue) => pathValue.includes('/release/') || pathValue.includes('release-'))
77
+
78
+ if (touchesDeployPrep && touchesLocalRepo) {
79
+ return 'fix: prompt for dirty deploy changes before version bump'
80
+ }
81
+
82
+ if (touchesCommitMessage && touchesReleaseFlow) {
83
+ return 'fix: tighten release commit suggestions'
84
+ }
85
+
86
+ return null
87
+ }
88
+
61
89
  function resolveWorkingTreeEntryLabel(entry) {
62
90
  if (entry.indexStatus === '?' && entry.worktreeStatus === '?') {
63
91
  return 'untracked'
@@ -198,6 +226,11 @@ export function sanitizeSuggestedCommitMessage(message) {
198
226
  }
199
227
 
200
228
  export function buildFallbackCommitMessage(statusEntries = []) {
229
+ const targetedFallback = buildTargetedFallbackCommitMessage(statusEntries)
230
+ if (targetedFallback) {
231
+ return targetedFallback
232
+ }
233
+
201
234
  const tokenCounts = new Map()
202
235
 
203
236
  for (const entry of statusEntries) {
@@ -272,7 +305,7 @@ async function buildCommitMessageContext(rootDir, {
272
305
  return statusEntries.map((entry) => `- ${summarizeWorkingTreeEntry(entry, {changeCountsByPath})}`).join('\n')
273
306
  }
274
307
 
275
- export async function suggestReleaseCommitMessage(rootDir = process.cwd(), {
308
+ export async function suggestCommitMessage(rootDir = process.cwd(), {
276
309
  runCommand,
277
310
  commandExistsImpl = commandExists,
278
311
  logStep,
@@ -310,6 +343,7 @@ export async function suggestReleaseCommitMessage(rootDir = process.cwd(), {
310
343
  'Use the exact format "<type>: <subject>" with no scope, no exclamation mark, and no extra text.',
311
344
  'Choose the most appropriate type from: fix, feat, chore, docs, refactor, test, style, perf, build, ci, revert.',
312
345
  'Make the subject specific enough to describe the actual behavior or workflow change, not just that files changed.',
346
+ 'Do not describe the commit itself, staging, or "pending changes"; describe the underlying behavior or workflow fix.',
313
347
  'Pending change summary:',
314
348
  commitContext || '- changed files present'
315
349
  ].join('\n\n')
@@ -329,3 +363,5 @@ export async function suggestReleaseCommitMessage(rootDir = process.cwd(), {
329
363
  }
330
364
  }
331
365
  }
366
+
367
+ export {suggestCommitMessage as suggestReleaseCommitMessage}
@@ -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
+ }