@wyxos/zephyr 0.4.5 → 0.4.7

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
@@ -68,6 +68,8 @@ When `--type node` or `--type vue` is used without a bump argument, Zephyr defau
68
68
 
69
69
  Interactive mode remains the default and is the best fit for first-time setup, config repair, and one-off deployments.
70
70
 
71
+ For app deployments, interactive mode now requires a real interactive terminal. If stdin/stdout are not attached to a TTY, Zephyr refuses to continue in interactive mode and tells you to rerun with --non-interactive --preset <name> --maintenance on|off.
72
+
71
73
  Non-interactive mode is strict and is intended for already-configured projects:
72
74
 
73
75
  - `--non-interactive` fails instead of prompting
@@ -77,6 +79,8 @@ Non-interactive mode is strict and is intended for already-configured projects:
77
79
  - stale remote locks are never auto-removed in non-interactive mode
78
80
  - `--json` is only supported together with `--non-interactive`
79
81
 
82
+ If Laravel maintenance mode has already been enabled and Zephyr then exits abnormally because of a signal such as `SIGINT`, `SIGTERM`, or `SIGHUP`, it now makes a best-effort attempt to run `artisan up` automatically before exiting.
83
+
80
84
  If Zephyr would normally prompt to:
81
85
 
82
86
  - create or repair config
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
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",
@@ -190,24 +190,16 @@ async function resolvePhpCommand({
190
190
  requiredPhpVersion,
191
191
  ssh,
192
192
  remoteCwd,
193
- logProcessing,
194
- logWarning
193
+ logProcessing
195
194
  } = {}) {
196
195
  if (!requiredPhpVersion) {
197
196
  return 'php'
198
197
  }
199
198
 
200
- try {
201
- const phpCommand = await findPhpBinary(ssh, remoteCwd, requiredPhpVersion)
202
- if (phpCommand !== 'php') {
203
- logProcessing?.(`Detected PHP requirement: ${requiredPhpVersion}, using ${phpCommand}`)
204
- }
199
+ const phpCommand = await findPhpBinary(ssh, remoteCwd, requiredPhpVersion)
200
+ logProcessing?.(`Detected PHP requirement: ${requiredPhpVersion}, using ${phpCommand}`)
205
201
 
206
- return phpCommand
207
- } catch (error) {
208
- logWarning?.(`Could not find PHP binary for version ${requiredPhpVersion}: ${error.message}`)
209
- return 'php'
210
- }
202
+ return phpCommand
211
203
  }
212
204
 
213
205
  function createMaintenanceModePlan({
@@ -59,7 +59,9 @@ async function maybeRecoverLaravelMaintenanceMode({
59
59
  runPrompt,
60
60
  logProcessing,
61
61
  logWarning,
62
- executionMode = {}
62
+ executionMode = {},
63
+ forceAutoRecovery = false,
64
+ reason = null
63
65
  } = {}) {
64
66
  if (!remotePlan?.remoteIsLaravel || !remotePlan?.maintenanceModeEnabled) {
65
67
  return
@@ -75,8 +77,11 @@ async function maybeRecoverLaravelMaintenanceMode({
75
77
  }
76
78
 
77
79
  try {
78
- if (executionMode?.interactive === false) {
79
- logProcessing?.('Deployment failed after Laravel maintenance mode was enabled. Running `artisan up` automatically...')
80
+ if (forceAutoRecovery || executionMode?.interactive === false) {
81
+ const reasonSuffix = typeof reason === 'string' && reason.length > 0
82
+ ? ` because of ${reason}`
83
+ : ''
84
+ logProcessing?.(`Deployment interrupted${reasonSuffix} after Laravel maintenance mode was enabled. Running \`artisan up\` automatically...`)
80
85
  await executeRemote(
81
86
  'Disable Laravel maintenance mode',
82
87
  remotePlan.maintenanceUpCommand ?? `${remotePlan.phpCommand} artisan up`
@@ -115,6 +120,129 @@ async function maybeRecoverLaravelMaintenanceMode({
115
120
  }
116
121
  }
117
122
 
123
+ function signalToExitCode(signal) {
124
+ const signalNumbers = {
125
+ SIGHUP: 1,
126
+ SIGINT: 2,
127
+ SIGTERM: 15
128
+ }
129
+
130
+ if (!signalNumbers[signal]) {
131
+ return null
132
+ }
133
+
134
+ return 128 + signalNumbers[signal]
135
+ }
136
+
137
+ export function createAbnormalExitGuard({
138
+ processRef = process,
139
+ cleanup = async () => {},
140
+ terminate = null,
141
+ logWarning
142
+ } = {}) {
143
+ const listeners = new Map()
144
+ const signals = ['SIGINT', 'SIGTERM', 'SIGHUP']
145
+ let active = true
146
+ let cleanupPromise = null
147
+
148
+ const terminateProcess = typeof terminate === 'function'
149
+ ? terminate
150
+ : async (signal) => {
151
+ const exitCode = signalToExitCode(signal)
152
+
153
+ if (typeof exitCode === 'number') {
154
+ processRef.exitCode = exitCode
155
+ }
156
+
157
+ if (typeof processRef.kill === 'function' && typeof processRef.pid === 'number') {
158
+ processRef.kill(processRef.pid, signal)
159
+ }
160
+ }
161
+
162
+ const unregister = () => {
163
+ if (!active) {
164
+ return
165
+ }
166
+
167
+ active = false
168
+
169
+ for (const [signal, handler] of listeners.entries()) {
170
+ if (typeof processRef.off === 'function') {
171
+ processRef.off(signal, handler)
172
+ continue
173
+ }
174
+
175
+ if (typeof processRef.removeListener === 'function') {
176
+ processRef.removeListener(signal, handler)
177
+ }
178
+ }
179
+
180
+ listeners.clear()
181
+ }
182
+
183
+ const run = async (signal) => {
184
+ if (!active) {
185
+ return cleanupPromise
186
+ }
187
+
188
+ if (cleanupPromise) {
189
+ return cleanupPromise
190
+ }
191
+
192
+ unregister()
193
+ cleanupPromise = (async () => {
194
+ try {
195
+ await cleanup(signal)
196
+ } catch (error) {
197
+ logWarning?.(`Best-effort deploy recovery after ${signal} failed: ${error.message}`)
198
+ } finally {
199
+ await terminateProcess(signal)
200
+ }
201
+ })()
202
+
203
+ return cleanupPromise
204
+ }
205
+
206
+ for (const signal of signals) {
207
+ const handler = () => {
208
+ void run(signal)
209
+ }
210
+
211
+ listeners.set(signal, handler)
212
+ processRef.once(signal, handler)
213
+ }
214
+
215
+ return {
216
+ unregister,
217
+ run
218
+ }
219
+ }
220
+
221
+ async function cleanupDeploymentResources({
222
+ rootDir,
223
+ ssh,
224
+ remoteCwd,
225
+ lockAcquired,
226
+ logWarning
227
+ } = {}) {
228
+ if (lockAcquired && ssh && remoteCwd) {
229
+ try {
230
+ await releaseRemoteLock(ssh, remoteCwd, {logWarning})
231
+ await releaseLocalLock(rootDir, {logWarning})
232
+ } catch (error) {
233
+ logWarning?.(`Failed to release lock: ${error.message}`)
234
+ }
235
+ }
236
+
237
+ await closeLogFile()
238
+
239
+ if (ssh) {
240
+ ssh.dispose()
241
+ }
242
+ }
243
+
244
+ export {maybeRecoverLaravelMaintenanceMode}
245
+
118
246
  export async function runDeployment(config, options = {}) {
119
247
  const {
120
248
  snapshot = null,
@@ -150,6 +278,64 @@ export async function runDeployment(config, options = {}) {
150
278
  }
151
279
 
152
280
  let lockAcquired = false
281
+ const abnormalExitGuard = createAbnormalExitGuard({
282
+ logWarning,
283
+ cleanup: async (signal) => {
284
+ let recoverySsh = null
285
+ let recoveryExecutor = executeRemote
286
+
287
+ try {
288
+ if (remoteCwd) {
289
+ ({ssh: recoverySsh} = await connectToRemoteDeploymentTarget({
290
+ config,
291
+ createSshClient,
292
+ sshUser,
293
+ privateKey,
294
+ remoteCwd,
295
+ logProcessing,
296
+ message: `Reconnecting to ${config.serverIp} as ${sshUser} for abnormal-exit recovery...`
297
+ }))
298
+
299
+ recoveryExecutor = createRemoteExecutor({
300
+ ssh: recoverySsh,
301
+ rootDir,
302
+ remoteCwd,
303
+ writeToLogFile,
304
+ logProcessing,
305
+ logSuccess,
306
+ logError
307
+ })
308
+ }
309
+
310
+ await maybeRecoverLaravelMaintenanceMode({
311
+ remotePlan,
312
+ executionState,
313
+ executeRemote: recoveryExecutor,
314
+ runPrompt,
315
+ logProcessing,
316
+ logWarning,
317
+ executionMode,
318
+ forceAutoRecovery: true,
319
+ reason: signal
320
+ })
321
+ } finally {
322
+ await cleanupDeploymentResources({
323
+ rootDir,
324
+ ssh: recoverySsh ?? ssh,
325
+ remoteCwd,
326
+ lockAcquired,
327
+ logWarning
328
+ })
329
+ lockAcquired = false
330
+
331
+ if (recoverySsh && ssh && recoverySsh !== ssh) {
332
+ ssh.dispose()
333
+ }
334
+
335
+ ssh = null
336
+ }
337
+ }
338
+ })
153
339
 
154
340
  try {
155
341
  ({ssh, remoteCwd} = await connectToRemoteDeploymentTarget({
@@ -280,18 +466,13 @@ export async function runDeployment(config, options = {}) {
280
466
 
281
467
  throw new Error(`Deployment failed: ${error.message}`)
282
468
  } finally {
283
- if (lockAcquired && ssh && remoteCwd) {
284
- try {
285
- await releaseRemoteLock(ssh, remoteCwd, {logWarning})
286
- await releaseLocalLock(rootDir, {logWarning})
287
- } catch (error) {
288
- logWarning(`Failed to release lock: ${error.message}`)
289
- }
290
- }
291
-
292
- await closeLogFile()
293
- if (ssh) {
294
- ssh.dispose()
295
- }
469
+ abnormalExitGuard.unregister()
470
+ await cleanupDeploymentResources({
471
+ rootDir,
472
+ ssh,
473
+ remoteCwd,
474
+ lockAcquired,
475
+ logWarning
476
+ })
296
477
  }
297
478
  }
@@ -382,7 +382,15 @@ export async function releaseNodePackage({
382
382
  })
383
383
 
384
384
  logStep?.('Checking working tree status...')
385
- await ensureCleanWorkingTree(rootDir, {runCommand})
385
+ await ensureCleanWorkingTree(rootDir, {
386
+ runCommand,
387
+ runPrompt,
388
+ logStep,
389
+ logSuccess,
390
+ logWarning,
391
+ interactive,
392
+ skipGitHooks
393
+ })
386
394
  await ensureReleaseBranchReady({rootDir, branchMethod: 'show-current', logStep, logWarning})
387
395
 
388
396
  await runLint(skipLint, pkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
@@ -249,7 +249,15 @@ export async function releasePackagistPackage({
249
249
  })
250
250
 
251
251
  logStep?.('Checking working tree status...')
252
- await ensureCleanWorkingTree(rootDir, {runCommand})
252
+ await ensureCleanWorkingTree(rootDir, {
253
+ runCommand,
254
+ runPrompt,
255
+ logStep,
256
+ logSuccess,
257
+ logWarning,
258
+ interactive,
259
+ skipGitHooks
260
+ })
253
261
  await ensureReleaseBranchReady({rootDir, branchMethod: 'show-current', logStep, logWarning})
254
262
 
255
263
  await runLint(skipLint, rootDir, {logStep, logSuccess, logWarning, runCommand, progressWriter})
@@ -2,6 +2,25 @@ import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import semver from 'semver'
4
4
 
5
+ function normalizeComposerConstraint(constraint) {
6
+ if (typeof constraint !== 'string') {
7
+ return null
8
+ }
9
+
10
+ return constraint
11
+ .replace(/\s*\|{1,2}\s*/g, ' || ')
12
+ .replace(/,/g, ' ')
13
+ .replace(/\s+@[\w.-]+/g, '')
14
+ .replace(/\s+/g, ' ')
15
+ .trim()
16
+ }
17
+
18
+ function getHighestVersion(versions = []) {
19
+ return versions
20
+ .filter((version) => semver.valid(version))
21
+ .reduce((highest, version) => (!highest || semver.gt(version, highest) ? version : highest), null)
22
+ }
23
+
5
24
  /**
6
25
  * Extracts the minimum PHP version requirement from a composer.json object
7
26
  * @param {object} composer - Parsed composer.json object
@@ -13,47 +32,78 @@ export function parsePhpVersionRequirement(composer) {
13
32
  return null
14
33
  }
15
34
 
16
- // Parse version constraint (e.g., "^8.4", ">=8.4.0", "8.4.*", "~8.4.0")
17
- // Extract the minimum version needed
18
- const versionMatch = phpRequirement.match(/(\d+)\.(\d+)(?:\.(\d+))?/)
19
- if (!versionMatch) {
35
+ const normalizedConstraint = normalizeComposerConstraint(phpRequirement)
36
+ if (normalizedConstraint) {
37
+ const minimumVersion = semver.minVersion(normalizedConstraint)
38
+ if (minimumVersion) {
39
+ return minimumVersion.version
40
+ }
41
+ }
42
+
43
+ const versionMatches = [...phpRequirement.matchAll(/(\d+)\.(\d+)(?:\.(\d+))?/g)]
44
+ if (versionMatches.length === 0) {
20
45
  return null
21
46
  }
22
47
 
23
- const major = versionMatch[1]
24
- const minor = versionMatch[2]
25
- const patch = versionMatch[3] || '0'
26
-
27
- const versionStr = `${major}.${minor}.${patch}`
28
-
29
- // Normalize to semver format
30
- if (semver.valid(versionStr)) {
31
- return versionStr
48
+ const versions = versionMatches
49
+ .map(([, major, minor, patch = '0']) => semver.coerce(`${major}.${minor}.${patch}`)?.version ?? null)
50
+ .filter(Boolean)
51
+
52
+ return versions.length > 0 ? versions.sort(semver.compare)[0] : null
53
+ }
54
+
55
+ export function parseComposerLockPhpVersionRequirement(lock) {
56
+ const versions = []
57
+ const platformPhpVersion = parsePhpVersionRequirement({require: {php: lock?.platform?.php}})
58
+ if (platformPhpVersion) {
59
+ versions.push(platformPhpVersion)
32
60
  }
33
-
34
- // Try to coerce to valid semver
35
- const coerced = semver.coerce(versionStr)
36
- if (coerced) {
37
- return coerced.version
61
+
62
+ const packages = Array.isArray(lock?.packages) ? lock.packages : []
63
+ for (const pkg of packages) {
64
+ const packagePhpVersion = parsePhpVersionRequirement({require: {php: pkg?.require?.php}})
65
+ if (packagePhpVersion) {
66
+ versions.push(packagePhpVersion)
67
+ }
38
68
  }
39
69
 
40
- return null
70
+ return getHighestVersion(versions)
41
71
  }
42
72
 
43
73
  /**
44
- * Extracts the minimum PHP version requirement from composer.json file
74
+ * Extracts the effective minimum PHP version requirement from composer.json and composer.lock.
75
+ * The lock file wins when runtime dependencies need a higher version than the root package declares.
45
76
  * @param {string} rootDir - Project root directory
46
77
  * @returns {Promise<string|null>} - PHP version requirement (e.g., "8.4.0") or null
47
78
  */
48
79
  export async function getPhpVersionRequirement(rootDir) {
80
+ const versions = []
81
+
49
82
  try {
50
83
  const composerPath = path.join(rootDir, 'composer.json')
51
84
  const raw = await fs.readFile(composerPath, 'utf8')
52
85
  const composer = JSON.parse(raw)
53
- return parsePhpVersionRequirement(composer)
86
+ const composerPhpVersion = parsePhpVersionRequirement(composer)
87
+ if (composerPhpVersion) {
88
+ versions.push(composerPhpVersion)
89
+ }
54
90
  } catch {
55
- return null
91
+ // Ignore and continue to composer.lock if present.
56
92
  }
93
+
94
+ try {
95
+ const lockPath = path.join(rootDir, 'composer.lock')
96
+ const raw = await fs.readFile(lockPath, 'utf8')
97
+ const lock = JSON.parse(raw)
98
+ const lockPhpVersion = parseComposerLockPhpVersionRequirement(lock)
99
+ if (lockPhpVersion) {
100
+ versions.push(lockPhpVersion)
101
+ }
102
+ } catch {
103
+ // Ignore when composer.lock is absent or unreadable.
104
+ }
105
+
106
+ return getHighestVersion(versions)
57
107
  }
58
108
 
59
109
  const RUNCLOUD_PACKAGES = '/RunCloud/Packages'
@@ -126,6 +176,8 @@ export async function findPhpBinary(ssh, remoteCwd, requiredVersion) {
126
176
  return 'php'
127
177
  }
128
178
 
179
+ let defaultPhpVersion = null
180
+
129
181
  const majorMinor = semver.major(requiredVersion) + '.' + semver.minor(requiredVersion)
130
182
  const versionedPhp = `php${majorMinor.replace('.', '')}` // e.g., "php84"
131
183
 
@@ -175,11 +227,16 @@ export async function findPhpBinary(ssh, remoteCwd, requiredVersion) {
175
227
  if (actualVersion && satisfiesVersion(actualVersion, requiredVersion)) {
176
228
  return 'php'
177
229
  }
230
+ defaultPhpVersion = actualVersion
178
231
  } catch {
179
232
  // Ignore
180
233
  }
181
234
 
182
- return 'php'
235
+ const defaultVersionHint = defaultPhpVersion
236
+ ? ` The default php command reports ${defaultPhpVersion}.`
237
+ : ''
238
+
239
+ throw new Error(`No PHP binary satisfying ${requiredVersion} was found on the remote server.${defaultVersionHint}`)
183
240
  }
184
241
 
185
242
  /**
package/src/main.mjs CHANGED
@@ -8,7 +8,7 @@ import {releaseNode} from './release-node.mjs'
8
8
  import {releasePackagist} from './release-packagist.mjs'
9
9
  import {validateLocalDependencies} from './dependency-scanner.mjs'
10
10
  import * as bootstrap from './project/bootstrap.mjs'
11
- import {getErrorCode} from './runtime/errors.mjs'
11
+ import {getErrorCode, ZephyrError} from './runtime/errors.mjs'
12
12
  import {PROJECT_CONFIG_DIR} from './utils/paths.mjs'
13
13
  import {writeStderrLine} from './utils/output.mjs'
14
14
  import {createAppContext} from './runtime/app-context.mjs'
@@ -73,6 +73,23 @@ function resolveWorkflowName(workflowType = null) {
73
73
  return 'deploy'
74
74
  }
75
75
 
76
+ function assertInteractiveAppDeploySession({workflowType = null, executionMode = {}, appContext = {}} = {}) {
77
+ const isAppDeploy = workflowType !== 'node' && workflowType !== 'vue' && workflowType !== 'packagist'
78
+
79
+ if (!isAppDeploy || executionMode?.interactive === false) {
80
+ return
81
+ }
82
+
83
+ if (appContext?.hasInteractiveTerminal !== false) {
84
+ return
85
+ }
86
+
87
+ throw new ZephyrError(
88
+ 'Zephyr refuses interactive app deployments without a real interactive terminal. Rerun in a TTY, or use --non-interactive --preset <name> --maintenance on|off.',
89
+ {code: 'ZEPHYR_INTERACTIVE_SESSION_REQUIRED'}
90
+ )
91
+ }
92
+
76
93
  async function runRemoteTasks(config, options = {}) {
77
94
  return await runDeployment(config, {
78
95
  ...options,
@@ -131,6 +148,12 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
131
148
  logWarning(SKIP_GIT_HOOKS_WARNING)
132
149
  }
133
150
 
151
+ assertInteractiveAppDeploySession({
152
+ workflowType: options.workflowType,
153
+ executionMode: currentExecutionMode,
154
+ appContext
155
+ })
156
+
134
157
  if (options.workflowType === 'node' || options.workflowType === 'vue') {
135
158
  await releaseNode({
136
159
  releaseType: options.versionArg,
@@ -0,0 +1,331 @@
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
+
6
+ import {commandExists} from '../utils/command.mjs'
7
+
8
+ const CONVENTIONAL_COMMIT_PATTERN = /^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test): .+/i
9
+ const GENERIC_SUBJECT_PATTERNS = [
10
+ /^commit pending (release )?changes$/i,
11
+ /^pending (release )?changes$/i,
12
+ /^commit changes$/i,
13
+ /^update changes$/i,
14
+ /^update files$/i,
15
+ /^update work$/i,
16
+ /^misc(ellaneous)?( updates?)?$/i,
17
+ /^changes$/i,
18
+ /^updates?$/i
19
+ ]
20
+ const MAX_WORKING_TREE_PREVIEW = 20
21
+ const STATUS_LABELS = {
22
+ A: 'added',
23
+ C: 'copied',
24
+ D: 'deleted',
25
+ M: 'modified',
26
+ R: 'renamed',
27
+ T: 'type-changed',
28
+ U: 'conflicted'
29
+ }
30
+ const TOPIC_STOP_WORDS = new Set([
31
+ 'src',
32
+ 'test',
33
+ 'tests',
34
+ '__tests__',
35
+ 'spec',
36
+ 'specs',
37
+ 'app',
38
+ 'lib',
39
+ 'dist',
40
+ 'packages',
41
+ 'package',
42
+ 'application',
43
+ 'shared',
44
+ 'index',
45
+ 'main',
46
+ 'js',
47
+ 'jsx',
48
+ 'ts',
49
+ 'tsx',
50
+ 'mjs',
51
+ 'cjs',
52
+ 'php',
53
+ 'json',
54
+ 'yaml',
55
+ 'yml',
56
+ 'md',
57
+ 'toml',
58
+ 'lock'
59
+ ])
60
+
61
+ function resolveWorkingTreeEntryLabel(entry) {
62
+ if (entry.indexStatus === '?' && entry.worktreeStatus === '?') {
63
+ return 'untracked'
64
+ }
65
+
66
+ if (entry.indexStatus === '!' && entry.worktreeStatus === '!') {
67
+ return 'ignored'
68
+ }
69
+
70
+ const relevantStatuses = [entry.indexStatus, entry.worktreeStatus].filter((status) => status && status !== ' ')
71
+ for (const status of relevantStatuses) {
72
+ if (STATUS_LABELS[status]) {
73
+ return STATUS_LABELS[status]
74
+ }
75
+ }
76
+
77
+ return 'changed'
78
+ }
79
+
80
+ function tokenizePath(pathValue = '') {
81
+ return pathValue
82
+ .split(/[\\/]/)
83
+ .flatMap((segment) => segment.split(/[^a-zA-Z0-9]+/))
84
+ .map((token) => token.toLowerCase())
85
+ .filter((token) => token.length >= 3 && !TOPIC_STOP_WORDS.has(token))
86
+ }
87
+
88
+ function inferCommitTypeFromEntries(statusEntries = []) {
89
+ const paths = statusEntries.map((entry) => entry.path.toLowerCase())
90
+
91
+ if (paths.every((pathValue) => pathValue.endsWith('.md') || pathValue.includes('/docs/') || pathValue.startsWith('docs/'))) {
92
+ return 'docs'
93
+ }
94
+
95
+ if (paths.every((pathValue) => /\.test\.[^.]+$/.test(pathValue) || pathValue.includes('/tests/'))) {
96
+ return 'test'
97
+ }
98
+
99
+ if (paths.some((pathValue) => pathValue.includes('.github/workflows/') || pathValue.includes('/ci/'))) {
100
+ return 'ci'
101
+ }
102
+
103
+ return 'chore'
104
+ }
105
+
106
+ export function parseWorkingTreeStatus(stdout = '') {
107
+ return stdout
108
+ .split(/\r?\n/)
109
+ .map((line) => line.trimEnd())
110
+ .filter(Boolean)
111
+ }
112
+
113
+ export function parseWorkingTreeEntries(stdout = '') {
114
+ return parseWorkingTreeStatus(stdout).map((line) => {
115
+ const indexStatus = line.slice(0, 1)
116
+ const worktreeStatus = line.slice(1, 2)
117
+ const rawPath = line.slice(3).trim()
118
+ const isRename = [indexStatus, worktreeStatus].some((status) => status === 'R' || status === 'C')
119
+ const [fromPath, toPath] = isRename && rawPath.includes(' -> ')
120
+ ? rawPath.split(' -> ')
121
+ : [null, null]
122
+
123
+ return {
124
+ raw: line,
125
+ indexStatus,
126
+ worktreeStatus,
127
+ path: toPath ?? rawPath,
128
+ previousPath: fromPath
129
+ }
130
+ })
131
+ }
132
+
133
+ export function summarizeWorkingTreeEntry(entry, {
134
+ changeCountsByPath = new Map()
135
+ } = {}) {
136
+ const label = resolveWorkingTreeEntryLabel(entry)
137
+ const displayPath = entry.previousPath ? `${entry.previousPath} -> ${entry.path}` : entry.path
138
+ const counts = changeCountsByPath.get(entry.path) ?? null
139
+
140
+ if (!counts) {
141
+ return `${label}: ${displayPath}`
142
+ }
143
+
144
+ return `${label}: ${displayPath} (+${counts.added} -${counts.deleted})`
145
+ }
146
+
147
+ export function formatWorkingTreePreview(statusEntries = []) {
148
+ const preview = statusEntries
149
+ .slice(0, MAX_WORKING_TREE_PREVIEW)
150
+ .map((entry) => ` ${summarizeWorkingTreeEntry(entry)}`)
151
+ .join('\n')
152
+
153
+ if (statusEntries.length <= MAX_WORKING_TREE_PREVIEW) {
154
+ return preview
155
+ }
156
+
157
+ const remaining = statusEntries.length - MAX_WORKING_TREE_PREVIEW
158
+ return `${preview}\n ...and ${remaining} more file${remaining === 1 ? '' : 's'}`
159
+ }
160
+
161
+ export function sanitizeSuggestedCommitMessage(message) {
162
+ if (typeof message !== 'string') {
163
+ return null
164
+ }
165
+
166
+ const firstLine = message
167
+ .split(/\r?\n/)
168
+ .map((line) => line.trim())
169
+ .find(Boolean)
170
+
171
+ if (!firstLine) {
172
+ return null
173
+ }
174
+
175
+ const normalized = firstLine
176
+ .replace(/^commit message:\s*/i, '')
177
+ .replace(/^(\w+)\([^)]+\)(!?):/i, '$1:')
178
+ .replace(/^(\w+)!:/i, '$1:')
179
+ .replace(/^["'`]+|["'`]+$/g, '')
180
+ .trim()
181
+
182
+ if (!CONVENTIONAL_COMMIT_PATTERN.test(normalized)) {
183
+ return null
184
+ }
185
+
186
+ const [, subject = ''] = normalized.split(/:\s+/, 2)
187
+ const normalizedSubject = subject.trim()
188
+
189
+ if (
190
+ normalizedSubject.length < 18 ||
191
+ normalizedSubject.split(/\s+/).length < 3 ||
192
+ GENERIC_SUBJECT_PATTERNS.some((pattern) => pattern.test(normalizedSubject))
193
+ ) {
194
+ return null
195
+ }
196
+
197
+ return normalized
198
+ }
199
+
200
+ export function buildFallbackCommitMessage(statusEntries = []) {
201
+ const tokenCounts = new Map()
202
+
203
+ for (const entry of statusEntries) {
204
+ for (const token of tokenizePath(entry.path)) {
205
+ tokenCounts.set(token, (tokenCounts.get(token) ?? 0) + 1)
206
+ }
207
+ }
208
+
209
+ const orderedTokens = Array.from(tokenCounts.entries())
210
+ .sort((left, right) => {
211
+ if (right[1] !== left[1]) {
212
+ return right[1] - left[1]
213
+ }
214
+
215
+ return left[0].localeCompare(right[0])
216
+ })
217
+ .map(([token]) => token)
218
+
219
+ const primaryTopic = orderedTokens[0] ?? 'release'
220
+ const commitType = inferCommitTypeFromEntries(statusEntries)
221
+
222
+ if (commitType === 'docs') {
223
+ return `docs: update ${primaryTopic} documentation`
224
+ }
225
+
226
+ if (commitType === 'test') {
227
+ return `test: expand ${primaryTopic} coverage`
228
+ }
229
+
230
+ if (commitType === 'ci') {
231
+ return `ci: update ${primaryTopic} workflow`
232
+ }
233
+
234
+ return `chore: improve ${primaryTopic} workflow`
235
+ }
236
+
237
+ async function collectDiffNumstat(rootDir, {runCommand} = {}) {
238
+ try {
239
+ const {stdout} = await runCommand('git', ['diff', '--numstat', 'HEAD', '--'], {
240
+ capture: true,
241
+ cwd: rootDir
242
+ })
243
+
244
+ return stdout
245
+ .split(/\r?\n/)
246
+ .map((line) => line.trim())
247
+ .filter(Boolean)
248
+ .reduce((map, line) => {
249
+ const [addedRaw, deletedRaw, filePath] = line.split('\t')
250
+ if (!filePath) {
251
+ return map
252
+ }
253
+
254
+ const added = Number.parseInt(addedRaw, 10)
255
+ const deleted = Number.parseInt(deletedRaw, 10)
256
+ map.set(filePath, {
257
+ added: Number.isFinite(added) ? added : 0,
258
+ deleted: Number.isFinite(deleted) ? deleted : 0
259
+ })
260
+ return map
261
+ }, new Map())
262
+ } catch {
263
+ return new Map()
264
+ }
265
+ }
266
+
267
+ async function buildCommitMessageContext(rootDir, {
268
+ runCommand,
269
+ statusEntries = []
270
+ } = {}) {
271
+ const changeCountsByPath = await collectDiffNumstat(rootDir, {runCommand})
272
+ return statusEntries.map((entry) => `- ${summarizeWorkingTreeEntry(entry, {changeCountsByPath})}`).join('\n')
273
+ }
274
+
275
+ export async function suggestReleaseCommitMessage(rootDir = process.cwd(), {
276
+ runCommand,
277
+ commandExistsImpl = commandExists,
278
+ logStep,
279
+ logWarning,
280
+ statusEntries = []
281
+ } = {}) {
282
+ if (!commandExistsImpl('codex')) {
283
+ return null
284
+ }
285
+
286
+ let tempDir = null
287
+
288
+ try {
289
+ tempDir = await mkdtemp(path.join(tmpdir(), 'zephyr-release-commit-'))
290
+ const outputPath = path.join(tempDir, 'codex-last-message.txt')
291
+ const commitContext = await buildCommitMessageContext(rootDir, {
292
+ runCommand,
293
+ statusEntries
294
+ })
295
+
296
+ logStep?.('Generating a suggested commit message with Codex...')
297
+
298
+ await runCommand('codex', [
299
+ 'exec',
300
+ '--ephemeral',
301
+ '--model',
302
+ 'gpt-5.4-mini',
303
+ '--sandbox',
304
+ 'read-only',
305
+ '--skip-git-repo-check',
306
+ '--output-last-message',
307
+ outputPath,
308
+ [
309
+ 'Write exactly one short conventional commit message for these pending changes.',
310
+ 'Use the exact format "<type>: <subject>" with no scope, no exclamation mark, and no extra text.',
311
+ 'Choose the most appropriate type from: fix, feat, chore, docs, refactor, test, style, perf, build, ci, revert.',
312
+ 'Make the subject specific enough to describe the actual behavior or workflow change, not just that files changed.',
313
+ 'Pending change summary:',
314
+ commitContext || '- changed files present'
315
+ ].join('\n\n')
316
+ ], {
317
+ capture: true,
318
+ cwd: rootDir
319
+ })
320
+
321
+ const rawMessage = await readFile(outputPath, 'utf8')
322
+ return sanitizeSuggestedCommitMessage(rawMessage)
323
+ } catch (error) {
324
+ logWarning?.(`Codex could not suggest a commit message: ${error.message}`)
325
+ return null
326
+ } finally {
327
+ if (tempDir) {
328
+ await rm(tempDir, {recursive: true, force: true}).catch(() => {})
329
+ }
330
+ }
331
+ }
@@ -1,13 +1,21 @@
1
1
  import inquirer from 'inquirer'
2
2
  import process from 'node:process'
3
3
 
4
- import { validateLocalDependencies } from '../dependency-scanner.mjs'
5
- import { runCommand as runCommandBase, runCommandCapture as runCommandCaptureBase } from '../utils/command.mjs'
4
+ import {validateLocalDependencies} from '../dependency-scanner.mjs'
5
+ import {runCommand as runCommandBase, runCommandCapture as runCommandCaptureBase} from '../utils/command.mjs'
6
+ import {gitCommitArgs} from '../utils/git-hooks.mjs'
6
7
  import {
7
8
  ensureUpToDateWithUpstream,
8
9
  getCurrentBranch,
9
10
  getUpstreamRef
10
11
  } from '../utils/git.mjs'
12
+ import {
13
+ buildFallbackCommitMessage,
14
+ formatWorkingTreePreview,
15
+ parseWorkingTreeEntries,
16
+ parseWorkingTreeStatus,
17
+ suggestReleaseCommitMessage
18
+ } from './commit-message.mjs'
11
19
 
12
20
  const RELEASE_TYPES = new Set([
13
21
  'major',
@@ -18,6 +26,8 @@ const RELEASE_TYPES = new Set([
18
26
  'prepatch',
19
27
  'prerelease'
20
28
  ])
29
+ const DIRTY_WORKING_TREE_MESSAGE = 'Working tree has uncommitted changes. Commit or stash them before releasing.'
30
+ const DIRTY_WORKING_TREE_CANCELLED_MESSAGE = 'Release cancelled: pending changes were not committed.'
21
31
 
22
32
  function flagToKey(flag) {
23
33
  return flag
@@ -60,7 +70,7 @@ export function parseReleaseArgs({
60
70
  booleanFlags.map((flag) => [flagToKey(flag), presentFlags.has(flag)])
61
71
  )
62
72
 
63
- return { releaseType, ...parsedFlags }
73
+ return {releaseType, ...parsedFlags}
64
74
  }
65
75
 
66
76
  export async function runReleaseCommand(command, args, {
@@ -70,32 +80,103 @@ export async function runReleaseCommand(command, args, {
70
80
  runCommandCaptureImpl = runCommandCaptureBase
71
81
  } = {}) {
72
82
  if (capture) {
73
- const captured = await runCommandCaptureImpl(command, args, { cwd })
83
+ const captured = await runCommandCaptureImpl(command, args, {cwd})
74
84
 
75
85
  if (typeof captured === 'string') {
76
- return { stdout: captured.trim(), stderr: '' }
86
+ return {stdout: captured.trim(), stderr: ''}
77
87
  }
78
88
 
79
89
  const stdout = captured?.stdout ?? ''
80
90
  const stderr = captured?.stderr ?? ''
81
- return { stdout: stdout.trim(), stderr: stderr.trim() }
91
+ return {stdout: stdout.trim(), stderr: stderr.trim()}
82
92
  }
83
93
 
84
- await runCommandImpl(command, args, { cwd })
94
+ await runCommandImpl(command, args, {cwd})
85
95
  return undefined
86
96
  }
87
97
 
88
98
  export async function ensureCleanWorkingTree(rootDir = process.cwd(), {
89
- runCommand = runReleaseCommand
99
+ runCommand = runReleaseCommand,
100
+ runPrompt,
101
+ logStep,
102
+ logSuccess,
103
+ logWarning,
104
+ interactive = true,
105
+ skipGitHooks = false,
106
+ suggestCommitMessage = suggestReleaseCommitMessage
90
107
  } = {}) {
91
- const { stdout } = await runCommand('git', ['status', '--porcelain'], {
108
+ const {stdout} = await runCommand('git', ['status', '--porcelain'], {
92
109
  capture: true,
93
110
  cwd: rootDir
94
111
  })
112
+ const statusEntries = parseWorkingTreeEntries(stdout)
95
113
 
96
- if (stdout.length > 0) {
97
- throw new Error('Working tree has uncommitted changes. Commit or stash them before releasing.')
114
+ if (statusEntries.length === 0) {
115
+ return
98
116
  }
117
+
118
+ if (!interactive || typeof runPrompt !== 'function') {
119
+ throw new Error(DIRTY_WORKING_TREE_MESSAGE)
120
+ }
121
+
122
+ const suggestedCommitMessage = await suggestCommitMessage(rootDir, {
123
+ runCommand,
124
+ logStep,
125
+ logWarning,
126
+ statusEntries
127
+ }) ?? buildFallbackCommitMessage(statusEntries)
128
+
129
+ const changeLabel = statusEntries.length === 1 ? 'change' : 'changes'
130
+ const {shouldCommitPendingChanges} = await runPrompt([
131
+ {
132
+ type: 'confirm',
133
+ name: 'shouldCommitPendingChanges',
134
+ message:
135
+ `Pending ${changeLabel} detected before release:\n\n` +
136
+ `${formatWorkingTreePreview(statusEntries)}\n\n` +
137
+ 'Stage and commit all current changes before continuing?',
138
+ default: true
139
+ }
140
+ ])
141
+
142
+ if (!shouldCommitPendingChanges) {
143
+ throw new Error(DIRTY_WORKING_TREE_CANCELLED_MESSAGE)
144
+ }
145
+
146
+ const {commitMessage} = await runPrompt([
147
+ {
148
+ type: 'input',
149
+ name: 'commitMessage',
150
+ message: 'Commit message for pending release changes',
151
+ default: suggestedCommitMessage,
152
+ validate: (value) => (value && value.trim().length > 0 ? true : 'Commit message cannot be empty.')
153
+ }
154
+ ])
155
+
156
+ const message = commitMessage.trim()
157
+
158
+ logStep?.('Staging all pending changes before release...')
159
+ await runCommand('git', ['add', '-A'], {
160
+ capture: true,
161
+ cwd: rootDir
162
+ })
163
+
164
+ logStep?.('Committing pending changes before release...')
165
+ await runCommand('git', gitCommitArgs(['-m', message], {skipGitHooks}), {
166
+ capture: true,
167
+ cwd: rootDir
168
+ })
169
+
170
+ const {stdout: finalStatus} = await runCommand('git', ['status', '--porcelain'], {
171
+ capture: true,
172
+ cwd: rootDir
173
+ })
174
+
175
+ if (parseWorkingTreeStatus(finalStatus).length > 0) {
176
+ throw new Error('Working tree still has uncommitted changes after the release commit. Commit or stash them before releasing.')
177
+ }
178
+
179
+ logSuccess?.(`Committed pending changes with "${message}".`)
99
180
  }
100
181
 
101
182
  export async function validateReleaseDependencies(rootDir = process.cwd(), {
@@ -119,7 +200,7 @@ export async function ensureReleaseBranchReady({
119
200
  logStep,
120
201
  logWarning
121
202
  } = {}) {
122
- const branch = await getCurrentBranchImpl(rootDir, { method: branchMethod })
203
+ const branch = await getCurrentBranchImpl(rootDir, {method: branchMethod})
123
204
 
124
205
  if (!branch) {
125
206
  throw new Error('Unable to determine current branch.')
@@ -128,7 +209,9 @@ export async function ensureReleaseBranchReady({
128
209
  logStep?.(`Current branch: ${branch}`)
129
210
 
130
211
  const upstreamRef = await getUpstreamRefImpl(rootDir)
131
- await ensureUpToDateWithUpstreamImpl({ branch, upstreamRef, rootDir, logStep, logWarning })
212
+ await ensureUpToDateWithUpstreamImpl({branch, upstreamRef, rootDir, logStep, logWarning})
132
213
 
133
- return { branch, upstreamRef }
214
+ return {branch, upstreamRef}
134
215
  }
216
+
217
+ export {suggestReleaseCommitMessage}
@@ -1,3 +1,4 @@
1
+ import process from 'node:process'
1
2
  import chalk from 'chalk'
2
3
  import inquirer from 'inquirer'
3
4
  import {NodeSSH} from 'node-ssh'
@@ -12,6 +13,7 @@ export function createAppContext({
12
13
  chalkInstance = chalk,
13
14
  inquirerInstance = inquirer,
14
15
  NodeSSHClass = NodeSSH,
16
+ processInstance = process,
15
17
  runCommandImpl = runCommandBase,
16
18
  runCommandCaptureImpl = runCommandCaptureBase,
17
19
  executionMode = {}
@@ -38,6 +40,7 @@ export function createAppContext({
38
40
  emitEvent,
39
41
  workflow: normalizedExecutionMode.workflow
40
42
  })
43
+ const hasInteractiveTerminal = Boolean(processInstance?.stdin?.isTTY && processInstance?.stdout?.isTTY)
41
44
  const createSshClient = createSshClientFactory({NodeSSH: NodeSSHClass})
42
45
  const {runCommand, runCommandCapture} = createLocalCommandRunners({
43
46
  runCommandBase: runCommandImpl,
@@ -59,6 +62,7 @@ export function createAppContext({
59
62
  runCommand: runCommandWithMode,
60
63
  runCommandCapture,
61
64
  emitEvent,
65
+ hasInteractiveTerminal,
62
66
  executionMode: normalizedExecutionMode
63
67
  }
64
68
  }