@wyxos/zephyr 0.7.5 → 0.8.1

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
@@ -49,6 +49,9 @@ zephyr minor --skip-checks
49
49
  # Deploy a configured app non-interactively
50
50
  zephyr --non-interactive --preset wyxos-release --maintenance off
51
51
 
52
+ # Configure a Laravel app target and verify SSH without deploying
53
+ zephyr --setup
54
+
52
55
  # Deploy a configured app non-interactively and auto-commit dirty changes
53
56
  zephyr --non-interactive --preset wyxos-release --auto-commit
54
57
 
@@ -67,6 +70,9 @@ zephyr --type node
67
70
  # Release a Node/Vue package with an explicit bump
68
71
  zephyr --type node minor
69
72
 
73
+ # Release a Node/Vue package, update a local consumer app, then deploy that app
74
+ zephyr --type node --then-deploy ../php/atlas --consumer-preset wyxos-release --consumer-package @wyxos/vibe
75
+
70
76
  # Release a Packagist package
71
77
  zephyr --type packagist patch
72
78
 
@@ -106,10 +112,14 @@ then non-interactive mode stops immediately with a clear error instead.
106
112
 
107
113
  For Laravel app deployments, `--maintenance on|off` overrides both the saved preset preference and the maintenance prompt when you want an explicit choice for the current run.
108
114
 
109
- `--auto-commit` is available for app deployments and tells Zephyr to let local Codex inspect the repo and generate the dirty-tree commit message instead of prompting for one.
115
+ `--setup` is Laravel-only. It first verifies that the current project is a local Laravel app, then runs the normal local configuration prompts, tests SSH authentication to the selected server, and exits before local deploy preparation, pending snapshot handling, maintenance-mode decisions, locks, or remote deployment commands. On non-Laravel projects it fails before local setup changes are written.
116
+
117
+ `--auto-commit` is available for app deployments and package releases. It tells Zephyr to let local Codex inspect the repo and generate the dirty-tree commit message instead of prompting for one.
110
118
 
111
119
  `--skip-versioning` keeps Zephyr from mutating `package.json` or `composer.json`. On app deploys it skips the local npm version bump step. On package release workflows it releases the version already present in the manifest and creates the release tag from the current `HEAD`.
112
120
 
121
+ `--then-deploy <path>` is available for `--type node` and `--type vue` package releases. After the package release succeeds and the published version is visible on npm, Zephyr updates the local consumer repository, commits the consumer dependency change, and deploys that consumer through its normal Zephyr app-deploy preset. Use `--consumer-preset <name>` to choose the app preset and `--consumer-package <name>` when the consumer dependency name differs from the released package name. Consumer lockfiles are refreshed only when the consumer repository already tracks an npm lockfile; Zephyr does not create or commit server-side lockfiles.
122
+
113
123
  ## AI Agents and Automation
114
124
 
115
125
  Zephyr can be used safely by Codex, CI jobs, or other automation once configuration is already in place.
@@ -127,6 +137,14 @@ zephyr --type node --non-interactive --json minor
127
137
  zephyr --type packagist --non-interactive --json patch
128
138
  ```
129
139
 
140
+ Recommended pattern for releasing a package and immediately deploying a consumer app:
141
+
142
+ ```bash
143
+ zephyr --type node --non-interactive --json --then-deploy ../php/atlas --consumer-preset wyxos-release --consumer-package @wyxos/vibe minor
144
+ ```
145
+
146
+ The consumer update happens in the local consumer repository before deployment. Production receives the committed consumer state through the normal deployment path.
147
+
130
148
  In `--json` mode Zephyr emits NDJSON events on `stdout` with a stable shape:
131
149
 
132
150
  - `run_started`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.7.5",
3
+ "version": "0.8.1",
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",
@@ -0,0 +1,72 @@
1
+ const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000
2
+ const DEFAULT_INTERVAL_MS = 10 * 1000
3
+
4
+ function sleep(ms) {
5
+ return new Promise((resolve) => setTimeout(resolve, ms))
6
+ }
7
+
8
+ function npmPackageMetadataUrl(packageName) {
9
+ return `https://registry.npmjs.org/${encodeURIComponent(packageName)}`
10
+ }
11
+
12
+ export async function waitForNpmPackageVersion({
13
+ packageName,
14
+ version,
15
+ timeoutMs = DEFAULT_TIMEOUT_MS,
16
+ intervalMs = DEFAULT_INTERVAL_MS,
17
+ fetchImpl = globalThis.fetch,
18
+ delayImpl = sleep,
19
+ nowImpl = Date.now,
20
+ logProcessing,
21
+ logSuccess,
22
+ logWarning
23
+ } = {}) {
24
+ if (!packageName) {
25
+ throw new Error('Package name is required before waiting for npm publication.')
26
+ }
27
+
28
+ if (!version) {
29
+ throw new Error('Package version is required before waiting for npm publication.')
30
+ }
31
+
32
+ if (typeof fetchImpl !== 'function') {
33
+ throw new Error('fetch is not available, so Zephyr cannot verify npm publication.')
34
+ }
35
+
36
+ const deadline = nowImpl() + timeoutMs
37
+ const url = npmPackageMetadataUrl(packageName)
38
+ let attempts = 0
39
+ let lastError = null
40
+
41
+ logProcessing?.(`Waiting for ${packageName}@${version} to be visible on npm...`)
42
+
43
+ while (nowImpl() <= deadline) {
44
+ attempts += 1
45
+
46
+ try {
47
+ const response = await fetchImpl(url)
48
+
49
+ if (response.ok) {
50
+ const metadata = await response.json()
51
+ if (metadata?.versions?.[version]) {
52
+ logSuccess?.(`${packageName}@${version} is visible on npm.`)
53
+ return {packageName, version, attempts}
54
+ }
55
+ } else {
56
+ lastError = new Error(`npm registry responded with ${response.status}`)
57
+ }
58
+ } catch (error) {
59
+ lastError = error
60
+ logWarning?.(`npm visibility check failed: ${error.message}`)
61
+ }
62
+
63
+ if (nowImpl() + intervalMs > deadline) {
64
+ break
65
+ }
66
+
67
+ await delayImpl(intervalMs)
68
+ }
69
+
70
+ const suffix = lastError ? ` Last error: ${lastError.message}` : ''
71
+ throw new Error(`Timed out waiting for ${packageName}@${version} to be visible on npm.${suffix}`)
72
+ }
@@ -0,0 +1,245 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import process from 'node:process'
4
+
5
+ import {validateLocalDependencies} from '../../dependency-scanner.mjs'
6
+ import * as bootstrap from '../../project/bootstrap.mjs'
7
+ import {createAppContext} from '../../runtime/app-context.mjs'
8
+ import {mergeDeployOptions} from '../../config/preset-options.mjs'
9
+ import {createConfigurationService} from '../configuration/service.mjs'
10
+ import {selectDeploymentTarget} from '../configuration/select-deployment-target.mjs'
11
+ import {resolvePendingSnapshot} from '../deploy/resolve-pending-snapshot.mjs'
12
+ import {runDeployment} from '../deploy/run-deployment.mjs'
13
+ import {waitForNpmPackageVersion} from './npm-publish-wait.mjs'
14
+ import {
15
+ assertCleanConsumerRepo,
16
+ updateConsumerDependency
17
+ } from './update-consumer-dependency.mjs'
18
+
19
+ function resolveConsumerRootDir(producerRootDir, consumerRootDir) {
20
+ if (!consumerRootDir) {
21
+ throw new Error('--then-deploy requires a consumer repository path.')
22
+ }
23
+
24
+ return path.isAbsolute(consumerRootDir)
25
+ ? path.resolve(consumerRootDir)
26
+ : path.resolve(producerRootDir, consumerRootDir)
27
+ }
28
+
29
+ async function fileExists(filePath) {
30
+ return fs.access(filePath).then(() => true).catch(() => false)
31
+ }
32
+
33
+ function createConsumerExecutionMode({
34
+ presetName,
35
+ maintenanceMode,
36
+ skipChecks = false,
37
+ skipTests = false,
38
+ skipLint = false,
39
+ skipVersioning = false,
40
+ skipGitHooks = false,
41
+ autoCommit = false,
42
+ json = false,
43
+ explicitMaintenanceMode = false,
44
+ explicitSkipChecks = false,
45
+ explicitSkipTests = false,
46
+ explicitSkipLint = false,
47
+ explicitSkipVersioning = false,
48
+ explicitSkipGitHooks = false,
49
+ explicitAutoCommit = false
50
+ } = {}) {
51
+ return {
52
+ interactive: false,
53
+ json: json === true,
54
+ workflow: 'deploy',
55
+ setup: false,
56
+ presetName,
57
+ maintenanceMode,
58
+ skipChecks: skipChecks === true,
59
+ skipTests: skipTests === true || skipChecks === true,
60
+ skipLint: skipLint === true || skipChecks === true,
61
+ skipVersioning: skipVersioning === true,
62
+ skipGitHooks: skipGitHooks === true,
63
+ autoCommit: autoCommit === true,
64
+ resumePending: false,
65
+ discardPending: false,
66
+ explicitMaintenanceMode: explicitMaintenanceMode === true,
67
+ explicitSkipChecks: explicitSkipChecks === true,
68
+ explicitSkipTests: explicitSkipTests === true || explicitSkipChecks === true,
69
+ explicitSkipLint: explicitSkipLint === true || explicitSkipChecks === true,
70
+ explicitSkipVersioning: explicitSkipVersioning === true,
71
+ explicitSkipGitHooks: explicitSkipGitHooks === true,
72
+ explicitAutoCommit: explicitAutoCommit === true
73
+ }
74
+ }
75
+
76
+ function resolvePackageDetails({releasedPackage, packageName, version}) {
77
+ const resolvedPackageName = packageName ?? releasedPackage?.name
78
+ const resolvedVersion = version ?? releasedPackage?.version
79
+
80
+ if (!resolvedPackageName) {
81
+ throw new Error('Unable to determine the package name to update in the consumer app. Pass --consumer-package <name>.')
82
+ }
83
+
84
+ if (!resolvedVersion) {
85
+ throw new Error('Unable to determine the released package version for the consumer app update.')
86
+ }
87
+
88
+ return {packageName: resolvedPackageName, version: resolvedVersion}
89
+ }
90
+
91
+ export async function releasePackageThenDeployConsumer({
92
+ producerRootDir,
93
+ consumerRootDir,
94
+ releasedPackage,
95
+ packageName = null,
96
+ version = null,
97
+ presetName,
98
+ maintenanceMode = null,
99
+ skipChecks = false,
100
+ skipTests = false,
101
+ skipLint = false,
102
+ skipVersioning = false,
103
+ skipGitHooks = false,
104
+ autoCommit = false,
105
+ json = false,
106
+ explicitMaintenanceMode = false,
107
+ explicitSkipChecks = false,
108
+ explicitSkipTests = false,
109
+ explicitSkipLint = false,
110
+ explicitSkipVersioning = false,
111
+ explicitSkipGitHooks = false,
112
+ explicitAutoCommit = false,
113
+ createAppContextImpl = createAppContext,
114
+ waitForNpmPackageVersionImpl = waitForNpmPackageVersion,
115
+ updateConsumerDependencyImpl = updateConsumerDependency,
116
+ assertCleanConsumerRepoImpl = assertCleanConsumerRepo,
117
+ validateLocalDependenciesImpl = validateLocalDependencies,
118
+ selectDeploymentTargetImpl = selectDeploymentTarget,
119
+ resolvePendingSnapshotImpl = resolvePendingSnapshot,
120
+ runDeploymentImpl = runDeployment,
121
+ bootstrapImpl = bootstrap
122
+ } = {}) {
123
+ if (!presetName) {
124
+ throw new Error('--then-deploy requires --consumer-preset <name>.')
125
+ }
126
+
127
+ const resolvedProducerRootDir = producerRootDir ?? process.cwd()
128
+ const resolvedConsumerRootDir = resolveConsumerRootDir(resolvedProducerRootDir, consumerRootDir)
129
+ const details = resolvePackageDetails({releasedPackage, packageName, version})
130
+ const executionMode = createConsumerExecutionMode({
131
+ presetName,
132
+ maintenanceMode,
133
+ skipChecks,
134
+ skipTests,
135
+ skipLint,
136
+ skipVersioning,
137
+ skipGitHooks,
138
+ autoCommit,
139
+ json,
140
+ explicitMaintenanceMode,
141
+ explicitSkipChecks,
142
+ explicitSkipTests,
143
+ explicitSkipLint,
144
+ explicitSkipVersioning,
145
+ explicitSkipGitHooks,
146
+ explicitAutoCommit
147
+ })
148
+ const context = createAppContextImpl({executionMode})
149
+ const {
150
+ logProcessing,
151
+ logSuccess,
152
+ logWarning,
153
+ runPrompt,
154
+ runCommand,
155
+ runCommandCapture,
156
+ emitEvent
157
+ } = context
158
+ const configurationService = createConfigurationService(context)
159
+
160
+ logProcessing?.(`Preparing consumer app at ${resolvedConsumerRootDir}...`)
161
+ await assertCleanConsumerRepoImpl(resolvedConsumerRootDir, {runCommandCapture})
162
+
163
+ await bootstrapImpl.ensureGitignoreEntry(resolvedConsumerRootDir, {
164
+ runCommand,
165
+ logSuccess,
166
+ logWarning,
167
+ skipGitHooks: executionMode.skipGitHooks
168
+ })
169
+ await bootstrapImpl.ensureProjectReleaseScript(resolvedConsumerRootDir, {
170
+ runPrompt,
171
+ runCommand,
172
+ logSuccess,
173
+ logWarning,
174
+ skipGitHooks: executionMode.skipGitHooks,
175
+ interactive: executionMode.interactive
176
+ })
177
+
178
+ const hasPackageJson = await fileExists(path.join(resolvedConsumerRootDir, 'package.json'))
179
+ const hasComposerJson = await fileExists(path.join(resolvedConsumerRootDir, 'composer.json'))
180
+
181
+ if (hasPackageJson || hasComposerJson) {
182
+ logProcessing?.('Validating consumer dependencies...')
183
+ await validateLocalDependenciesImpl(resolvedConsumerRootDir, runPrompt, logSuccess, {
184
+ interactive: executionMode.interactive,
185
+ skipGitHooks: executionMode.skipGitHooks
186
+ })
187
+ }
188
+
189
+ const {deploymentConfig, presetState} = await selectDeploymentTargetImpl(resolvedConsumerRootDir, {
190
+ configurationService,
191
+ runPrompt,
192
+ logProcessing,
193
+ logSuccess,
194
+ logWarning,
195
+ emitEvent,
196
+ executionMode,
197
+ promptPresetOptions: true
198
+ })
199
+
200
+ if (presetState) {
201
+ const effectiveDeployOptions = mergeDeployOptions(executionMode, presetState.options)
202
+ Object.assign(executionMode, {
203
+ presetName: presetState.name,
204
+ ...effectiveDeployOptions,
205
+ skipChecks: executionMode.skipChecks === true ||
206
+ (effectiveDeployOptions.skipTests === true && effectiveDeployOptions.skipLint === true)
207
+ })
208
+ context.executionMode = executionMode
209
+ await presetState.applyExecutionMode(executionMode)
210
+ }
211
+
212
+ await waitForNpmPackageVersionImpl({
213
+ packageName: details.packageName,
214
+ version: details.version,
215
+ logProcessing,
216
+ logSuccess,
217
+ logWarning
218
+ })
219
+
220
+ await updateConsumerDependencyImpl({
221
+ rootDir: resolvedConsumerRootDir,
222
+ packageName: details.packageName,
223
+ version: details.version,
224
+ runCommand,
225
+ runCommandCapture,
226
+ logProcessing,
227
+ logSuccess,
228
+ logWarning,
229
+ skipGitHooks: executionMode.skipGitHooks
230
+ })
231
+
232
+ const snapshotToUse = await resolvePendingSnapshotImpl(resolvedConsumerRootDir, deploymentConfig, {
233
+ runPrompt,
234
+ logProcessing,
235
+ logWarning,
236
+ executionMode
237
+ })
238
+
239
+ await runDeploymentImpl(deploymentConfig, {
240
+ rootDir: resolvedConsumerRootDir,
241
+ snapshot: snapshotToUse,
242
+ context,
243
+ presetState
244
+ })
245
+ }
@@ -0,0 +1,159 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import {gitCommitArgs} from '../../utils/git-hooks.mjs'
5
+
6
+ const DEPENDENCY_FIELDS = [
7
+ 'dependencies',
8
+ 'devDependencies',
9
+ 'peerDependencies',
10
+ 'optionalDependencies'
11
+ ]
12
+ const TRACKED_NPM_LOCK_FILES = ['package-lock.json', 'npm-shrinkwrap.json']
13
+
14
+ async function readPackageJson(rootDir) {
15
+ const packageJsonPath = path.join(rootDir, 'package.json')
16
+ const raw = await fs.readFile(packageJsonPath, 'utf8')
17
+ return JSON.parse(raw)
18
+ }
19
+
20
+ async function writePackageJson(rootDir, pkg) {
21
+ const packageJsonPath = path.join(rootDir, 'package.json')
22
+ await fs.writeFile(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`, 'utf8')
23
+ }
24
+
25
+ function findDependencyField(pkg, packageName) {
26
+ return DEPENDENCY_FIELDS.find((field) => Object.hasOwn(pkg?.[field] ?? {}, packageName)) ?? null
27
+ }
28
+
29
+ function formatDependencyVersion(currentValue, version) {
30
+ if (typeof currentValue !== 'string' || currentValue.length === 0) {
31
+ return `^${version}`
32
+ }
33
+
34
+ if (currentValue.startsWith('~')) {
35
+ return `~${version}`
36
+ }
37
+
38
+ if (currentValue.startsWith('^')) {
39
+ return `^${version}`
40
+ }
41
+
42
+ if (/^\d/.test(currentValue)) {
43
+ return version
44
+ }
45
+
46
+ return `^${version}`
47
+ }
48
+
49
+ export async function assertCleanConsumerRepo(rootDir, {runCommandCapture} = {}) {
50
+ const status = await runCommandCapture('git', ['status', '--porcelain'], {cwd: rootDir})
51
+ const normalizedStatus = typeof status === 'string' ? status : status?.stdout ?? ''
52
+
53
+ if (normalizedStatus.trim().length > 0) {
54
+ throw new Error('Consumer repository has uncommitted changes. Commit, stash, or clean them before running a package-to-consumer release.')
55
+ }
56
+ }
57
+
58
+ async function isTracked(rootDir, filePath, {runCommandCapture} = {}) {
59
+ try {
60
+ await runCommandCapture('git', ['ls-files', '--error-unmatch', filePath], {cwd: rootDir})
61
+ return true
62
+ } catch {
63
+ return false
64
+ }
65
+ }
66
+
67
+ async function getChangedFiles(rootDir, files, {runCommandCapture} = {}) {
68
+ const status = await runCommandCapture('git', ['status', '--porcelain', '--', ...files], {cwd: rootDir})
69
+ const normalizedStatus = typeof status === 'string' ? status : status?.stdout ?? ''
70
+ return normalizedStatus
71
+ .split('\n')
72
+ .map((line) => line.trim())
73
+ .filter(Boolean)
74
+ }
75
+
76
+ async function trackedNpmLockFiles(rootDir, {runCommandCapture} = {}) {
77
+ const tracked = []
78
+
79
+ for (const filePath of TRACKED_NPM_LOCK_FILES) {
80
+ if (await isTracked(rootDir, filePath, {runCommandCapture})) {
81
+ tracked.push(filePath)
82
+ }
83
+ }
84
+
85
+ return tracked
86
+ }
87
+
88
+ export async function updateConsumerDependency({
89
+ rootDir,
90
+ packageName,
91
+ version,
92
+ runCommand,
93
+ runCommandCapture,
94
+ logProcessing,
95
+ logSuccess,
96
+ logWarning,
97
+ skipGitHooks = false
98
+ } = {}) {
99
+ if (!rootDir) {
100
+ throw new Error('Consumer root directory is required.')
101
+ }
102
+
103
+ if (!packageName) {
104
+ throw new Error('Consumer package name is required.')
105
+ }
106
+
107
+ if (!version) {
108
+ throw new Error('Consumer package version is required.')
109
+ }
110
+
111
+ if (typeof runCommand !== 'function' || typeof runCommandCapture !== 'function') {
112
+ throw new Error('Consumer dependency updates require command runners.')
113
+ }
114
+
115
+ await assertCleanConsumerRepo(rootDir, {runCommandCapture})
116
+
117
+ const pkg = await readPackageJson(rootDir)
118
+ const dependencyField = findDependencyField(pkg, packageName)
119
+
120
+ if (!dependencyField) {
121
+ throw new Error(`Consumer package.json does not depend on ${packageName}. Add it before running --then-deploy.`)
122
+ }
123
+
124
+ const currentValue = pkg[dependencyField][packageName]
125
+ const nextValue = formatDependencyVersion(currentValue, version)
126
+ const lockFiles = await trackedNpmLockFiles(rootDir, {runCommandCapture})
127
+
128
+ if (currentValue !== nextValue) {
129
+ logProcessing?.(`Updating ${packageName} in consumer package.json from ${currentValue} to ${nextValue}...`)
130
+ pkg[dependencyField][packageName] = nextValue
131
+ await writePackageJson(rootDir, pkg)
132
+ } else {
133
+ logProcessing?.(`Consumer package.json already references ${packageName}@${nextValue}.`)
134
+ }
135
+
136
+ if (lockFiles.length > 0) {
137
+ logProcessing?.(`Refreshing tracked npm lock file${lockFiles.length === 1 ? '' : 's'}: ${lockFiles.join(', ')}...`)
138
+ await runCommand('npm', ['install', '--package-lock-only', '--ignore-scripts'], {cwd: rootDir})
139
+ } else {
140
+ logWarning?.('No tracked npm lock file found in consumer repo; committing manifest update only.')
141
+ }
142
+
143
+ const filesToCommit = ['package.json', ...lockFiles]
144
+ const changedFiles = await getChangedFiles(rootDir, filesToCommit, {runCommandCapture})
145
+
146
+ if (changedFiles.length === 0) {
147
+ logSuccess?.(`Consumer already uses ${packageName}@${version}.`)
148
+ return {committed: false, files: []}
149
+ }
150
+
151
+ const commitMessage = `chore: update ${packageName} to ${version}`
152
+
153
+ await runCommand('git', ['add', '--', ...filesToCommit], {cwd: rootDir})
154
+ await runCommand('git', gitCommitArgs(['-m', commitMessage, '--', ...filesToCommit], {skipGitHooks}), {cwd: rootDir})
155
+
156
+ logSuccess?.(`Committed consumer dependency update with "${commitMessage}".`)
157
+
158
+ return {committed: true, files: filesToCommit, message: commitMessage}
159
+ }
@@ -15,6 +15,7 @@ import {resolveRemotePath} from '../../utils/remote-path.mjs'
15
15
  import {buildRemoteDeploymentPlan, resolveRemoteDeploymentState} from './build-remote-deployment-plan.mjs'
16
16
  import {executeRemoteDeploymentPlan} from './execute-remote-deployment-plan.mjs'
17
17
  import {prepareLocalDeployment} from './prepare-local-deployment.mjs'
18
+ import {verifyLaravelSetup} from './verify-laravel-setup.mjs'
18
19
 
19
20
  async function resolveRemoteHome(ssh, sshUser) {
20
21
  const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
@@ -263,9 +264,22 @@ export async function runDeployment(config, options = {}) {
263
264
  executionMode
264
265
  } = context
265
266
 
267
+ const sshUser = config.sshUser || os.userInfo().username
268
+
269
+ if (executionMode?.setup === true) {
270
+ await verifyLaravelSetup({
271
+ config,
272
+ rootDir,
273
+ createSshClient,
274
+ sshUser,
275
+ logProcessing,
276
+ logSuccess
277
+ })
278
+ return
279
+ }
280
+
266
281
  await cleanupOldLogs(rootDir)
267
282
 
268
- const sshUser = config.sshUser || os.userInfo().username
269
283
  const privateKeyPath = await resolveSshKeyPath(config.sshKey)
270
284
  const privateKey = await fs.readFile(privateKeyPath, 'utf8')
271
285
  let ssh = null
@@ -0,0 +1,43 @@
1
+ import fs from 'node:fs/promises'
2
+
3
+ import {isLocalLaravelProject} from '../../deploy/preflight.mjs'
4
+ import {ZephyrError} from '../../runtime/errors.mjs'
5
+ import {resolveSshKeyPath} from '../../ssh/keys.mjs'
6
+
7
+ export async function assertLaravelSetupProject(rootDir) {
8
+ const isLaravel = await isLocalLaravelProject(rootDir)
9
+
10
+ if (!isLaravel) {
11
+ throw new ZephyrError(
12
+ 'Zephyr setup is only supported for Laravel app projects.',
13
+ {code: 'ZEPHYR_SETUP_REQUIRES_LARAVEL'}
14
+ )
15
+ }
16
+ }
17
+
18
+ export async function verifyLaravelSetup({
19
+ config,
20
+ rootDir,
21
+ createSshClient,
22
+ sshUser,
23
+ logProcessing,
24
+ logSuccess
25
+ } = {}) {
26
+ await assertLaravelSetupProject(rootDir)
27
+
28
+ const privateKeyPath = await resolveSshKeyPath(config.sshKey)
29
+ const privateKey = await fs.readFile(privateKeyPath, 'utf8')
30
+ const ssh = createSshClient()
31
+
32
+ try {
33
+ logProcessing?.(`\nConnecting to ${config.serverIp} as ${sshUser} to verify SSH setup...`)
34
+ await ssh.connect({
35
+ host: config.serverIp,
36
+ username: sshUser,
37
+ privateKey
38
+ })
39
+ logSuccess?.('Setup verified. SSH connection succeeded for this Laravel app.')
40
+ } finally {
41
+ ssh.dispose()
42
+ }
43
+ }
@@ -391,6 +391,7 @@ async function deployGHPages(skipDeploy, pkg, rootDir = process.cwd(), {
391
391
  export async function releaseNodePackage({
392
392
  releaseType,
393
393
  skipGitHooks = false,
394
+ autoCommit = false,
394
395
  skipTests = false,
395
396
  skipLint = false,
396
397
  skipVersioning = false,
@@ -431,6 +432,7 @@ export async function releaseNodePackage({
431
432
  logSuccess,
432
433
  logWarning,
433
434
  interactive,
435
+ autoCommit,
434
436
  skipGitHooks
435
437
  })
436
438
  await ensureReleaseBranchReady({rootDir, branchMethod: 'show-current', logStep, logWarning})
@@ -472,4 +474,5 @@ export async function releaseNodePackage({
472
474
 
473
475
  logStep?.('Publishing will be handled by GitHub Actions via trusted publishing.')
474
476
  logSuccess?.(`Release workflow completed for ${updatedPkg.name}@${updatedPkg.version}.`)
477
+ return updatedPkg
475
478
  }
@@ -240,6 +240,7 @@ async function pushChanges(rootDir = process.cwd(), {
240
240
  export async function releasePackagistPackage({
241
241
  releaseType,
242
242
  skipGitHooks = false,
243
+ autoCommit = false,
243
244
  skipTests = false,
244
245
  skipLint = false,
245
246
  skipVersioning = false,
@@ -286,6 +287,7 @@ export async function releasePackagistPackage({
286
287
  logSuccess,
287
288
  logWarning,
288
289
  interactive,
290
+ autoCommit,
289
291
  skipGitHooks
290
292
  })
291
293
  await ensureReleaseBranchReady({rootDir, branchMethod: 'show-current', logStep, logWarning})
@@ -324,4 +326,5 @@ export async function releasePackagistPackage({
324
326
 
325
327
  logSuccess?.(`Release workflow completed for ${composer.name}@${updatedComposer.version}.`)
326
328
  logStep?.('Note: Packagist will automatically detect the new git tag and update the package.')
329
+ return updatedComposer
327
330
  }
@@ -35,12 +35,17 @@ export function parseCliOptions(args = process.argv.slice(2)) {
35
35
  .exitOverride()
36
36
  .option('--type <type>', 'Workflow type (node|vue|packagist). Omit for normal app deployments.')
37
37
  .option('--non-interactive', 'Fail instead of prompting when Zephyr needs user input.')
38
+ .option('--then-deploy <path>', 'After a node/vue package release, update and deploy a local consumer app repo.')
39
+ .option('--consumer-package <name>', 'Package name to update in the --then-deploy consumer. Defaults to the released package name.')
40
+ .option('--consumer-preset <name>', 'Preset name to use for the --then-deploy consumer app deployment.')
41
+ .option('--consumer-maintenance <mode>', 'Laravel maintenance mode policy for the --then-deploy consumer app deployment (on|off).')
38
42
  .option('--json', 'Emit NDJSON events to stdout. Requires --non-interactive.')
43
+ .option('--setup', 'Configure an app deployment target and verify SSH connectivity without deploying.')
39
44
  .option('--preset <name>', 'Preset name to use for non-interactive app deployments.')
40
45
  .option('--resume-pending', 'Resume a saved pending deployment snapshot without prompting.')
41
46
  .option('--discard-pending', 'Discard a saved pending deployment snapshot without prompting.')
42
47
  .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.')
48
+ .option('--auto-commit', 'Automatically commit dirty changes with a Codex-generated message.')
44
49
  .option('--skip-versioning', 'Skip updating package/composer version files before continuing.')
45
50
  .option('--skip-git-hooks', 'Bypass local git hooks for any commits and pushes Zephyr performs.')
46
51
  .option('--skip-checks', 'Skip Zephyr local lint and test execution.')
@@ -48,6 +53,12 @@ export function parseCliOptions(args = process.argv.slice(2)) {
48
53
  .option('--skip-lint', 'Skip Zephyr local lint execution in package release and app deployment workflows.')
49
54
  .option('--skip-build', 'Skip build execution in node/vue release workflows.')
50
55
  .option('--skip-deploy', 'Skip GitHub Pages deployment in node/vue release workflows.')
56
+ .option('--consumer-skip-checks', 'Skip Zephyr local lint and test execution in the --then-deploy consumer deployment.')
57
+ .option('--consumer-skip-tests', 'Skip Zephyr local test execution in the --then-deploy consumer deployment.')
58
+ .option('--consumer-skip-lint', 'Skip Zephyr local lint execution in the --then-deploy consumer deployment.')
59
+ .option('--consumer-skip-versioning', 'Skip local version bumping in the --then-deploy consumer deployment.')
60
+ .option('--consumer-skip-git-hooks', 'Bypass local git hooks for commits and pushes in the --then-deploy consumer repo.')
61
+ .option('--consumer-auto-commit', 'Automatically commit dirty deploy changes in the --then-deploy consumer repo.')
51
62
  .argument(
52
63
  '[version]',
53
64
  'Version or npm bump type for deployments (e.g. 1.2.3, patch, minor, major).'
@@ -62,6 +73,7 @@ export function parseCliOptions(args = process.argv.slice(2)) {
62
73
  const options = program.opts()
63
74
  const workflowType = options.type ?? null
64
75
  const explicitSkipChecks = hasFlag(args, '--skip-checks')
76
+ const explicitConsumerSkipChecks = hasFlag(args, '--consumer-skip-checks')
65
77
 
66
78
  if (workflowType && !WORKFLOW_TYPES.has(workflowType)) {
67
79
  throw new InvalidCliOptionsError('Invalid value for --type. Use one of: node, vue, packagist.')
@@ -71,7 +83,9 @@ export function parseCliOptions(args = process.argv.slice(2)) {
71
83
  workflowType,
72
84
  versionArg: program.args[0] ?? null,
73
85
  nonInteractive: Boolean(options.nonInteractive),
86
+ thenDeploy: options.thenDeploy ?? null,
74
87
  json: Boolean(options.json),
88
+ setup: Boolean(options.setup),
75
89
  presetName: options.preset ?? null,
76
90
  resumePending: Boolean(options.resumePending),
77
91
  discardPending: Boolean(options.discardPending),
@@ -84,13 +98,29 @@ export function parseCliOptions(args = process.argv.slice(2)) {
84
98
  skipLint: Boolean(options.skipLint || options.skipChecks),
85
99
  skipBuild: Boolean(options.skipBuild),
86
100
  skipDeploy: Boolean(options.skipDeploy),
101
+ consumerPackage: options.consumerPackage ?? null,
102
+ consumerPresetName: options.consumerPreset ?? null,
103
+ consumerMaintenanceMode: normalizeMaintenanceMode(options.consumerMaintenance),
104
+ consumerSkipChecks: Boolean(options.consumerSkipChecks),
105
+ consumerSkipTests: Boolean(options.consumerSkipTests || options.consumerSkipChecks),
106
+ consumerSkipLint: Boolean(options.consumerSkipLint || options.consumerSkipChecks),
107
+ consumerSkipVersioning: Boolean(options.consumerSkipVersioning),
108
+ consumerSkipGitHooks: Boolean(options.consumerSkipGitHooks),
109
+ consumerAutoCommit: Boolean(options.consumerAutoCommit),
87
110
  explicitMaintenanceMode: hasFlag(args, '--maintenance'),
88
111
  explicitAutoCommit: hasFlag(args, '--auto-commit'),
89
112
  explicitSkipVersioning: hasFlag(args, '--skip-versioning'),
90
113
  explicitSkipGitHooks: hasFlag(args, '--skip-git-hooks'),
91
114
  explicitSkipChecks,
92
115
  explicitSkipTests: hasFlag(args, '--skip-tests') || explicitSkipChecks,
93
- explicitSkipLint: hasFlag(args, '--skip-lint') || explicitSkipChecks
116
+ explicitSkipLint: hasFlag(args, '--skip-lint') || explicitSkipChecks,
117
+ explicitConsumerMaintenanceMode: hasFlag(args, '--consumer-maintenance'),
118
+ explicitConsumerSkipChecks,
119
+ explicitConsumerSkipTests: hasFlag(args, '--consumer-skip-tests') || explicitConsumerSkipChecks,
120
+ explicitConsumerSkipLint: hasFlag(args, '--consumer-skip-lint') || explicitConsumerSkipChecks,
121
+ explicitConsumerSkipVersioning: hasFlag(args, '--consumer-skip-versioning'),
122
+ explicitConsumerSkipGitHooks: hasFlag(args, '--consumer-skip-git-hooks'),
123
+ explicitConsumerAutoCommit: hasFlag(args, '--consumer-auto-commit')
94
124
  }
95
125
  }
96
126
 
@@ -99,14 +129,29 @@ export function validateCliOptions(options = {}) {
99
129
  workflowType = null,
100
130
  nonInteractive = false,
101
131
  json = false,
132
+ setup = false,
133
+ thenDeploy = null,
102
134
  presetName = null,
103
135
  resumePending = false,
104
136
  discardPending = false,
105
137
  maintenanceMode = null,
106
138
  autoCommit = false,
107
139
  skipVersioning = false,
140
+ skipChecks = false,
141
+ skipTests = false,
142
+ skipLint = false,
108
143
  skipBuild = false,
109
- skipDeploy = false
144
+ skipDeploy = false,
145
+ versionArg = null,
146
+ consumerPackage = null,
147
+ consumerPresetName = null,
148
+ consumerMaintenanceMode = null,
149
+ consumerSkipChecks = false,
150
+ consumerSkipTests = false,
151
+ consumerSkipLint = false,
152
+ consumerSkipVersioning = false,
153
+ consumerSkipGitHooks = false,
154
+ consumerAutoCommit = false
110
155
  } = options
111
156
 
112
157
  if (json && !nonInteractive) {
@@ -118,8 +163,37 @@ export function validateCliOptions(options = {}) {
118
163
  }
119
164
 
120
165
  const isPackageRelease = workflowType === 'node' || workflowType === 'vue' || workflowType === 'packagist'
166
+ const isNodePackageRelease = workflowType === 'node' || workflowType === 'vue'
167
+ const hasConsumerOptions = Boolean(
168
+ thenDeploy ||
169
+ consumerPackage ||
170
+ consumerPresetName ||
171
+ consumerMaintenanceMode !== null ||
172
+ consumerSkipChecks ||
173
+ consumerSkipTests ||
174
+ consumerSkipLint ||
175
+ consumerSkipVersioning ||
176
+ consumerSkipGitHooks ||
177
+ consumerAutoCommit
178
+ )
179
+
180
+ if (hasConsumerOptions && !thenDeploy) {
181
+ throw new InvalidCliOptionsError('--consumer-* options require --then-deploy <path>.')
182
+ }
183
+
184
+ if (thenDeploy && !isNodePackageRelease) {
185
+ throw new InvalidCliOptionsError('--then-deploy is only valid for node/vue package release workflows.')
186
+ }
187
+
188
+ if (thenDeploy && !consumerPresetName) {
189
+ throw new InvalidCliOptionsError('--then-deploy requires --consumer-preset <name>.')
190
+ }
121
191
 
122
192
  if (isPackageRelease) {
193
+ if (setup) {
194
+ throw new InvalidCliOptionsError('--setup is only valid for app deployments.')
195
+ }
196
+
123
197
  if (presetName) {
124
198
  throw new InvalidCliOptionsError('--preset is only valid for app deployments.')
125
199
  }
@@ -131,21 +205,39 @@ export function validateCliOptions(options = {}) {
131
205
  if (maintenanceMode !== null) {
132
206
  throw new InvalidCliOptionsError('--maintenance is only valid for app deployments.')
133
207
  }
134
-
135
- if (autoCommit) {
136
- throw new InvalidCliOptionsError('--auto-commit is only valid for app deployments.')
137
- }
138
208
  } else {
139
209
  if (skipBuild || skipDeploy) {
140
210
  throw new InvalidCliOptionsError('--skip-build and --skip-deploy are only valid for node/vue release workflows.')
141
211
  }
142
212
 
213
+ if (setup) {
214
+ if (versionArg) {
215
+ throw new InvalidCliOptionsError('--setup cannot be used with a version or bump argument.')
216
+ }
217
+
218
+ if (resumePending || discardPending) {
219
+ throw new InvalidCliOptionsError('--setup cannot be used with pending deployment snapshot flags.')
220
+ }
221
+
222
+ if (maintenanceMode !== null) {
223
+ throw new InvalidCliOptionsError('--setup cannot be used with --maintenance.')
224
+ }
225
+
226
+ if (autoCommit) {
227
+ throw new InvalidCliOptionsError('--setup cannot be used with --auto-commit.')
228
+ }
229
+
230
+ if (skipVersioning || skipChecks || skipTests || skipLint) {
231
+ throw new InvalidCliOptionsError('--setup cannot be used with deployment skip flags.')
232
+ }
233
+ }
234
+
143
235
  if (nonInteractive && !presetName) {
144
236
  throw new InvalidCliOptionsError('--non-interactive app deployments require --preset <name>.')
145
237
  }
146
238
  }
147
239
 
148
- if (skipVersioning && options.versionArg) {
240
+ if (skipVersioning && versionArg) {
149
241
  throw new InvalidCliOptionsError('--skip-versioning cannot be used together with an explicit version or bump argument.')
150
242
  }
151
243
  }
package/src/main.mjs CHANGED
@@ -17,6 +17,8 @@ import {createConfigurationService} from './application/configuration/service.mj
17
17
  import {selectDeploymentTarget} from './application/configuration/select-deployment-target.mjs'
18
18
  import {resolvePendingSnapshot} from './application/deploy/resolve-pending-snapshot.mjs'
19
19
  import {runDeployment} from './application/deploy/run-deployment.mjs'
20
+ import {assertLaravelSetupProject} from './application/deploy/verify-laravel-setup.mjs'
21
+ import {releasePackageThenDeployConsumer} from './application/consumer/release-package-then-deploy-consumer.mjs'
20
22
  import {SKIP_GIT_HOOKS_WARNING} from './utils/git-hooks.mjs'
21
23
  import {notifyWorkflowResult} from './utils/notifications.mjs'
22
24
 
@@ -31,7 +33,9 @@ function normalizeMainOptions(firstArg = null, secondArg = null) {
31
33
  workflowType: firstArg.workflowType ?? firstArg.type ?? null,
32
34
  versionArg: firstArg.versionArg ?? null,
33
35
  nonInteractive: firstArg.nonInteractive === true,
36
+ thenDeploy: firstArg.thenDeploy ?? null,
34
37
  json: firstArg.json === true,
38
+ setup: firstArg.setup === true,
35
39
  presetName: firstArg.presetName ?? null,
36
40
  resumePending: firstArg.resumePending === true,
37
41
  discardPending: firstArg.discardPending === true,
@@ -44,6 +48,22 @@ function normalizeMainOptions(firstArg = null, secondArg = null) {
44
48
  skipLint: firstArg.skipLint === true || firstArg.skipChecks === true,
45
49
  skipBuild: firstArg.skipBuild === true,
46
50
  skipDeploy: firstArg.skipDeploy === true,
51
+ consumerPackage: firstArg.consumerPackage ?? null,
52
+ consumerPresetName: firstArg.consumerPresetName ?? null,
53
+ consumerMaintenanceMode: firstArg.consumerMaintenanceMode ?? null,
54
+ consumerSkipChecks: firstArg.consumerSkipChecks === true,
55
+ consumerSkipTests: firstArg.consumerSkipTests === true || firstArg.consumerSkipChecks === true,
56
+ consumerSkipLint: firstArg.consumerSkipLint === true || firstArg.consumerSkipChecks === true,
57
+ consumerSkipVersioning: firstArg.consumerSkipVersioning === true,
58
+ consumerSkipGitHooks: firstArg.consumerSkipGitHooks === true,
59
+ consumerAutoCommit: firstArg.consumerAutoCommit === true,
60
+ explicitConsumerMaintenanceMode: firstArg.explicitConsumerMaintenanceMode === true || 'consumerMaintenanceMode' in firstArg,
61
+ explicitConsumerSkipChecks: firstArg.explicitConsumerSkipChecks === true || 'consumerSkipChecks' in firstArg,
62
+ explicitConsumerSkipTests: firstArg.explicitConsumerSkipTests === true || 'consumerSkipTests' in firstArg || 'consumerSkipChecks' in firstArg,
63
+ explicitConsumerSkipLint: firstArg.explicitConsumerSkipLint === true || 'consumerSkipLint' in firstArg || 'consumerSkipChecks' in firstArg,
64
+ explicitConsumerSkipVersioning: firstArg.explicitConsumerSkipVersioning === true || 'consumerSkipVersioning' in firstArg,
65
+ explicitConsumerSkipGitHooks: firstArg.explicitConsumerSkipGitHooks === true || 'consumerSkipGitHooks' in firstArg,
66
+ explicitConsumerAutoCommit: firstArg.explicitConsumerAutoCommit === true || 'consumerAutoCommit' in firstArg,
47
67
  explicitMaintenanceMode: firstArg.explicitMaintenanceMode === true || 'maintenanceMode' in firstArg,
48
68
  explicitAutoCommit: firstArg.explicitAutoCommit === true || 'autoCommit' in firstArg,
49
69
  explicitSkipVersioning: firstArg.explicitSkipVersioning === true || 'skipVersioning' in firstArg,
@@ -59,7 +79,9 @@ function normalizeMainOptions(firstArg = null, secondArg = null) {
59
79
  workflowType: firstArg ?? null,
60
80
  versionArg: secondArg ?? null,
61
81
  nonInteractive: false,
82
+ thenDeploy: null,
62
83
  json: false,
84
+ setup: false,
63
85
  presetName: null,
64
86
  resumePending: false,
65
87
  discardPending: false,
@@ -72,6 +94,22 @@ function normalizeMainOptions(firstArg = null, secondArg = null) {
72
94
  skipLint: false,
73
95
  skipBuild: false,
74
96
  skipDeploy: false,
97
+ consumerPackage: null,
98
+ consumerPresetName: null,
99
+ consumerMaintenanceMode: null,
100
+ consumerSkipChecks: false,
101
+ consumerSkipTests: false,
102
+ consumerSkipLint: false,
103
+ consumerSkipVersioning: false,
104
+ consumerSkipGitHooks: false,
105
+ consumerAutoCommit: false,
106
+ explicitConsumerMaintenanceMode: false,
107
+ explicitConsumerSkipChecks: false,
108
+ explicitConsumerSkipTests: false,
109
+ explicitConsumerSkipLint: false,
110
+ explicitConsumerSkipVersioning: false,
111
+ explicitConsumerSkipGitHooks: false,
112
+ explicitConsumerAutoCommit: false,
75
113
  explicitMaintenanceMode: false,
76
114
  explicitAutoCommit: false,
77
115
  explicitSkipVersioning: false,
@@ -127,6 +165,7 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
127
165
  interactive: !options.nonInteractive,
128
166
  json: options.json === true && options.nonInteractive === true,
129
167
  workflow: resolveWorkflowName(options.workflowType),
168
+ setup: options.setup === true,
130
169
  presetName: options.presetName,
131
170
  maintenanceMode: options.maintenanceMode,
132
171
  autoCommit: options.autoCommit === true,
@@ -157,7 +196,8 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
157
196
  } = appContext
158
197
  let currentExecutionMode = {
159
198
  ...executionMode,
160
- ...(appContext.executionMode ?? {})
199
+ ...(appContext.executionMode ?? {}),
200
+ setup: executionMode.setup === true || appContext.executionMode?.setup === true
161
201
  }
162
202
  appContext.executionMode = currentExecutionMode
163
203
  const configurationService = createConfigurationService(appContext)
@@ -171,6 +211,7 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
171
211
  data: {
172
212
  version: ZEPHYR_VERSION,
173
213
  workflow: currentExecutionMode.workflow,
214
+ setup: currentExecutionMode.setup === true,
174
215
  nonInteractive: currentExecutionMode.interactive === false,
175
216
  presetName: currentExecutionMode.presetName,
176
217
  maintenanceMode: currentExecutionMode.maintenanceMode,
@@ -198,9 +239,14 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
198
239
  appContext
199
240
  })
200
241
 
242
+ if (currentExecutionMode.setup) {
243
+ await assertLaravelSetupProject(rootDir)
244
+ }
245
+
201
246
  if (options.workflowType === 'node' || options.workflowType === 'vue') {
202
- await releaseNode({
247
+ const releasedPackage = await releaseNode({
203
248
  releaseType: options.versionArg,
249
+ autoCommit: options.autoCommit,
204
250
  skipGitHooks: options.skipGitHooks,
205
251
  skipTests: options.skipTests,
206
252
  skipLint: options.skipLint,
@@ -209,6 +255,32 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
209
255
  skipDeploy: options.skipDeploy,
210
256
  context: appContext
211
257
  })
258
+
259
+ if (options.thenDeploy) {
260
+ await releasePackageThenDeployConsumer({
261
+ producerRootDir: rootDir,
262
+ consumerRootDir: options.thenDeploy,
263
+ releasedPackage,
264
+ packageName: options.consumerPackage,
265
+ presetName: options.consumerPresetName,
266
+ maintenanceMode: options.consumerMaintenanceMode,
267
+ skipChecks: options.consumerSkipChecks,
268
+ skipTests: options.consumerSkipTests,
269
+ skipLint: options.consumerSkipLint,
270
+ skipVersioning: options.consumerSkipVersioning,
271
+ skipGitHooks: options.consumerSkipGitHooks,
272
+ autoCommit: options.consumerAutoCommit,
273
+ explicitMaintenanceMode: options.explicitConsumerMaintenanceMode,
274
+ explicitSkipChecks: options.explicitConsumerSkipChecks,
275
+ explicitSkipTests: options.explicitConsumerSkipTests,
276
+ explicitSkipLint: options.explicitConsumerSkipLint,
277
+ explicitSkipVersioning: options.explicitConsumerSkipVersioning,
278
+ explicitSkipGitHooks: options.explicitConsumerSkipGitHooks,
279
+ explicitAutoCommit: options.explicitConsumerAutoCommit,
280
+ json: currentExecutionMode.json
281
+ })
282
+ }
283
+
212
284
  emitEvent?.('run_completed', {
213
285
  message: 'Zephyr workflow completed successfully.',
214
286
  data: {
@@ -230,12 +302,14 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
230
302
  if (options.workflowType === 'packagist') {
231
303
  await releasePackagist({
232
304
  releaseType: options.versionArg,
305
+ autoCommit: options.autoCommit,
233
306
  skipGitHooks: options.skipGitHooks,
234
307
  skipTests: options.skipTests,
235
308
  skipLint: options.skipLint,
236
309
  skipVersioning: options.skipVersioning,
237
310
  context: appContext
238
311
  })
312
+
239
313
  emitEvent?.('run_completed', {
240
314
  message: 'Zephyr workflow completed successfully.',
241
315
  data: {
@@ -277,7 +351,7 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
277
351
  const hasPackageJson = await fs.access(packageJsonPath).then(() => true).catch(() => false)
278
352
  const hasComposerJson = await fs.access(composerJsonPath).then(() => true).catch(() => false)
279
353
 
280
- if (hasPackageJson || hasComposerJson) {
354
+ if (!currentExecutionMode.setup && (hasPackageJson || hasComposerJson)) {
281
355
  logProcessing('Validating dependencies...')
282
356
  await validateLocalDependencies(rootDir, runPrompt, logSuccess, {
283
357
  interactive: currentExecutionMode.interactive,
@@ -292,7 +366,8 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
292
366
  logSuccess,
293
367
  logWarning,
294
368
  emitEvent,
295
- executionMode: currentExecutionMode
369
+ executionMode: currentExecutionMode,
370
+ promptPresetOptions: currentExecutionMode.setup !== true
296
371
  })
297
372
 
298
373
  if (presetState) {
@@ -308,12 +383,14 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
308
383
  await presetState.applyExecutionMode(currentExecutionMode)
309
384
  }
310
385
 
311
- const snapshotToUse = await resolvePendingSnapshot(rootDir, deploymentConfig, {
312
- runPrompt,
313
- logProcessing,
314
- logWarning,
315
- executionMode: currentExecutionMode
316
- })
386
+ const snapshotToUse = currentExecutionMode.setup
387
+ ? null
388
+ : await resolvePendingSnapshot(rootDir, deploymentConfig, {
389
+ runPrompt,
390
+ logProcessing,
391
+ logWarning,
392
+ executionMode: currentExecutionMode
393
+ })
317
394
 
318
395
  await runRemoteTasks(deploymentConfig, {
319
396
  rootDir,
@@ -94,6 +94,7 @@ export async function ensureCleanWorkingTree(rootDir = process.cwd(), {
94
94
  logSuccess,
95
95
  logWarning,
96
96
  interactive = true,
97
+ autoCommit = false,
97
98
  skipGitHooks = false,
98
99
  suggestCommitMessage = suggestReleaseCommitMessage
99
100
  } = {}) {
@@ -107,7 +108,7 @@ export async function ensureCleanWorkingTree(rootDir = process.cwd(), {
107
108
  return
108
109
  }
109
110
 
110
- if (!interactive || typeof runPrompt !== 'function') {
111
+ if (!autoCommit && (!interactive || typeof runPrompt !== 'function')) {
111
112
  throw new Error(DIRTY_WORKING_TREE_MESSAGE)
112
113
  }
113
114
 
@@ -117,24 +118,34 @@ export async function ensureCleanWorkingTree(rootDir = process.cwd(), {
117
118
  logWarning,
118
119
  statusEntries
119
120
  })
120
- const {commitMessage} = await runPrompt([
121
- {
122
- type: 'input',
123
- name: 'commitMessage',
124
- message:
125
- 'Pending changes detected before release:\n\n' +
126
- `${formatWorkingTreePreview(statusEntries)}\n\n` +
127
- 'Enter a commit message to stage and commit all current changes before continuing.\n' +
128
- 'Leave blank to cancel.',
129
- default: suggestedCommitMessage ?? ''
121
+ let message = suggestedCommitMessage?.trim() ?? ''
122
+
123
+ if (autoCommit) {
124
+ if (!message) {
125
+ throw new Error('Release auto-commit failed because Codex could not determine a usable commit message.')
130
126
  }
131
- ])
132
127
 
133
- if (!commitMessage || commitMessage.trim().length === 0) {
134
- throw new Error(DIRTY_WORKING_TREE_CANCELLED_MESSAGE)
135
- }
128
+ logStep?.(`Auto-commit enabled. Using Codex-generated commit message "${message}".`)
129
+ } else {
130
+ const {commitMessage} = await runPrompt([
131
+ {
132
+ type: 'input',
133
+ name: 'commitMessage',
134
+ message:
135
+ 'Pending changes detected before release:\n\n' +
136
+ `${formatWorkingTreePreview(statusEntries)}\n\n` +
137
+ 'Enter a commit message to stage and commit all current changes before continuing.\n' +
138
+ 'Leave blank to cancel.',
139
+ default: message
140
+ }
141
+ ])
142
+
143
+ if (!commitMessage || commitMessage.trim().length === 0) {
144
+ throw new Error(DIRTY_WORKING_TREE_CANCELLED_MESSAGE)
145
+ }
136
146
 
137
- const message = commitMessage.trim()
147
+ message = commitMessage.trim()
148
+ }
138
149
 
139
150
  logStep?.('Staging all pending changes before release...')
140
151
  await runCommand('git', ['add', '-A'], {
@@ -10,6 +10,7 @@ function hasExplicitReleaseOptions(options = {}) {
10
10
  'skipTests',
11
11
  'skipLint',
12
12
  'skipVersioning',
13
+ 'autoCommit',
13
14
  'skipBuild',
14
15
  'skipDeploy'
15
16
  ].some((key) => key in options)
@@ -23,11 +24,12 @@ export async function releaseNode(options = {}) {
23
24
  skipTests: options.skipTests === true,
24
25
  skipLint: options.skipLint === true,
25
26
  skipVersioning: options.skipVersioning === true,
27
+ autoCommit: options.autoCommit === true,
26
28
  skipBuild: options.skipBuild === true,
27
29
  skipDeploy: options.skipDeploy === true
28
30
  }
29
31
  : parseReleaseArgs({
30
- booleanFlags: ['--skip-git-hooks', '--skip-tests', '--skip-lint', '--skip-versioning', '--skip-build', '--skip-deploy']
32
+ booleanFlags: ['--skip-git-hooks', '--auto-commit', '--skip-tests', '--skip-lint', '--skip-versioning', '--skip-build', '--skip-deploy']
31
33
  })
32
34
 
33
35
  if (parsed.skipVersioning && parsed.releaseType) {
@@ -43,10 +45,11 @@ export async function releaseNode(options = {}) {
43
45
  })
44
46
  const {logProcessing: logStep, logSuccess, logWarning, runPrompt, runCommand, runCommandCapture, executionMode} = context
45
47
 
46
- await releaseNodePackage({
48
+ return await releaseNodePackage({
47
49
  releaseType: parsed.releaseType,
48
50
  skipGitHooks: parsed.skipGitHooks === true || executionMode?.skipGitHooks === true,
49
51
  skipTests: parsed.skipTests === true,
52
+ autoCommit: parsed.autoCommit === true,
50
53
  skipLint: parsed.skipLint === true,
51
54
  skipVersioning: parsed.skipVersioning === true,
52
55
  skipBuild: parsed.skipBuild === true,
@@ -9,6 +9,7 @@ function hasExplicitReleaseOptions(options = {}) {
9
9
  'skipGitHooks',
10
10
  'skipTests',
11
11
  'skipLint',
12
+ 'autoCommit',
12
13
  'skipVersioning'
13
14
  ].some((key) => key in options)
14
15
  }
@@ -20,10 +21,11 @@ export async function releasePackagist(options = {}) {
20
21
  skipGitHooks: options.skipGitHooks === true,
21
22
  skipTests: options.skipTests === true,
22
23
  skipLint: options.skipLint === true,
24
+ autoCommit: options.autoCommit === true,
23
25
  skipVersioning: options.skipVersioning === true
24
26
  }
25
27
  : parseReleaseArgs({
26
- booleanFlags: ['--skip-git-hooks', '--skip-tests', '--skip-lint', '--skip-versioning']
28
+ booleanFlags: ['--skip-git-hooks', '--auto-commit', '--skip-tests', '--skip-lint', '--skip-versioning']
27
29
  })
28
30
 
29
31
  if (parsed.skipVersioning && parsed.releaseType) {
@@ -39,9 +41,10 @@ export async function releasePackagist(options = {}) {
39
41
  })
40
42
  const {logProcessing: logStep, logSuccess, logWarning, runPrompt, runCommand, runCommandCapture, executionMode} = context
41
43
 
42
- await releasePackagistPackage({
44
+ return await releasePackagistPackage({
43
45
  releaseType: parsed.releaseType,
44
46
  skipGitHooks: parsed.skipGitHooks === true || executionMode?.skipGitHooks === true,
47
+ autoCommit: parsed.autoCommit === true,
45
48
  skipTests: parsed.skipTests === true,
46
49
  skipLint: parsed.skipLint === true,
47
50
  skipVersioning: parsed.skipVersioning === true,
@@ -22,6 +22,7 @@ export function createAppContext({
22
22
  interactive: executionMode.interactive !== false,
23
23
  json: executionMode.json === true,
24
24
  workflow: executionMode.workflow ?? 'deploy',
25
+ setup: executionMode.setup === true,
25
26
  presetName: executionMode.presetName ?? null,
26
27
  maintenanceMode: executionMode.maintenanceMode ?? null,
27
28
  autoCommit: executionMode.autoCommit === true,