@wyxos/zephyr 0.8.0 → 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
@@ -70,6 +70,9 @@ zephyr --type node
70
70
  # Release a Node/Vue package with an explicit bump
71
71
  zephyr --type node minor
72
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
+
73
76
  # Release a Packagist package
74
77
  zephyr --type packagist patch
75
78
 
@@ -111,10 +114,12 @@ For Laravel app deployments, `--maintenance on|off` overrides both the saved pre
111
114
 
112
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.
113
116
 
114
- `--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.
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.
115
118
 
116
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`.
117
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
+
118
123
  ## AI Agents and Automation
119
124
 
120
125
  Zephyr can be used safely by Codex, CI jobs, or other automation once configuration is already in place.
@@ -132,6 +137,14 @@ zephyr --type node --non-interactive --json minor
132
137
  zephyr --type packagist --non-interactive --json patch
133
138
  ```
134
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
+
135
148
  In `--json` mode Zephyr emits NDJSON events on `stdout` with a stable shape:
136
149
 
137
150
  - `run_started`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.8.0",
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
+ }
@@ -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,13 +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.')
39
43
  .option('--setup', 'Configure an app deployment target and verify SSH connectivity without deploying.')
40
44
  .option('--preset <name>', 'Preset name to use for non-interactive app deployments.')
41
45
  .option('--resume-pending', 'Resume a saved pending deployment snapshot without prompting.')
42
46
  .option('--discard-pending', 'Discard a saved pending deployment snapshot without prompting.')
43
47
  .option('--maintenance <mode>', 'Laravel maintenance mode policy for app deployments (on|off).')
44
- .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.')
45
49
  .option('--skip-versioning', 'Skip updating package/composer version files before continuing.')
46
50
  .option('--skip-git-hooks', 'Bypass local git hooks for any commits and pushes Zephyr performs.')
47
51
  .option('--skip-checks', 'Skip Zephyr local lint and test execution.')
@@ -49,6 +53,12 @@ export function parseCliOptions(args = process.argv.slice(2)) {
49
53
  .option('--skip-lint', 'Skip Zephyr local lint execution in package release and app deployment workflows.')
50
54
  .option('--skip-build', 'Skip build execution in node/vue release workflows.')
51
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.')
52
62
  .argument(
53
63
  '[version]',
54
64
  'Version or npm bump type for deployments (e.g. 1.2.3, patch, minor, major).'
@@ -63,6 +73,7 @@ export function parseCliOptions(args = process.argv.slice(2)) {
63
73
  const options = program.opts()
64
74
  const workflowType = options.type ?? null
65
75
  const explicitSkipChecks = hasFlag(args, '--skip-checks')
76
+ const explicitConsumerSkipChecks = hasFlag(args, '--consumer-skip-checks')
66
77
 
67
78
  if (workflowType && !WORKFLOW_TYPES.has(workflowType)) {
68
79
  throw new InvalidCliOptionsError('Invalid value for --type. Use one of: node, vue, packagist.')
@@ -72,6 +83,7 @@ export function parseCliOptions(args = process.argv.slice(2)) {
72
83
  workflowType,
73
84
  versionArg: program.args[0] ?? null,
74
85
  nonInteractive: Boolean(options.nonInteractive),
86
+ thenDeploy: options.thenDeploy ?? null,
75
87
  json: Boolean(options.json),
76
88
  setup: Boolean(options.setup),
77
89
  presetName: options.preset ?? null,
@@ -86,13 +98,29 @@ export function parseCliOptions(args = process.argv.slice(2)) {
86
98
  skipLint: Boolean(options.skipLint || options.skipChecks),
87
99
  skipBuild: Boolean(options.skipBuild),
88
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),
89
110
  explicitMaintenanceMode: hasFlag(args, '--maintenance'),
90
111
  explicitAutoCommit: hasFlag(args, '--auto-commit'),
91
112
  explicitSkipVersioning: hasFlag(args, '--skip-versioning'),
92
113
  explicitSkipGitHooks: hasFlag(args, '--skip-git-hooks'),
93
114
  explicitSkipChecks,
94
115
  explicitSkipTests: hasFlag(args, '--skip-tests') || explicitSkipChecks,
95
- 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')
96
124
  }
97
125
  }
98
126
 
@@ -102,6 +130,7 @@ export function validateCliOptions(options = {}) {
102
130
  nonInteractive = false,
103
131
  json = false,
104
132
  setup = false,
133
+ thenDeploy = null,
105
134
  presetName = null,
106
135
  resumePending = false,
107
136
  discardPending = false,
@@ -113,7 +142,16 @@ export function validateCliOptions(options = {}) {
113
142
  skipLint = false,
114
143
  skipBuild = false,
115
144
  skipDeploy = false,
116
- versionArg = null
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
117
155
  } = options
118
156
 
119
157
  if (json && !nonInteractive) {
@@ -125,6 +163,31 @@ export function validateCliOptions(options = {}) {
125
163
  }
126
164
 
127
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
+ }
128
191
 
129
192
  if (isPackageRelease) {
130
193
  if (setup) {
@@ -142,10 +205,6 @@ export function validateCliOptions(options = {}) {
142
205
  if (maintenanceMode !== null) {
143
206
  throw new InvalidCliOptionsError('--maintenance is only valid for app deployments.')
144
207
  }
145
-
146
- if (autoCommit) {
147
- throw new InvalidCliOptionsError('--auto-commit is only valid for app deployments.')
148
- }
149
208
  } else {
150
209
  if (skipBuild || skipDeploy) {
151
210
  throw new InvalidCliOptionsError('--skip-build and --skip-deploy are only valid for node/vue release workflows.')
package/src/main.mjs CHANGED
@@ -18,6 +18,7 @@ import {selectDeploymentTarget} from './application/configuration/select-deploym
18
18
  import {resolvePendingSnapshot} from './application/deploy/resolve-pending-snapshot.mjs'
19
19
  import {runDeployment} from './application/deploy/run-deployment.mjs'
20
20
  import {assertLaravelSetupProject} from './application/deploy/verify-laravel-setup.mjs'
21
+ import {releasePackageThenDeployConsumer} from './application/consumer/release-package-then-deploy-consumer.mjs'
21
22
  import {SKIP_GIT_HOOKS_WARNING} from './utils/git-hooks.mjs'
22
23
  import {notifyWorkflowResult} from './utils/notifications.mjs'
23
24
 
@@ -32,6 +33,7 @@ function normalizeMainOptions(firstArg = null, secondArg = null) {
32
33
  workflowType: firstArg.workflowType ?? firstArg.type ?? null,
33
34
  versionArg: firstArg.versionArg ?? null,
34
35
  nonInteractive: firstArg.nonInteractive === true,
36
+ thenDeploy: firstArg.thenDeploy ?? null,
35
37
  json: firstArg.json === true,
36
38
  setup: firstArg.setup === true,
37
39
  presetName: firstArg.presetName ?? null,
@@ -46,6 +48,22 @@ function normalizeMainOptions(firstArg = null, secondArg = null) {
46
48
  skipLint: firstArg.skipLint === true || firstArg.skipChecks === true,
47
49
  skipBuild: firstArg.skipBuild === true,
48
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,
49
67
  explicitMaintenanceMode: firstArg.explicitMaintenanceMode === true || 'maintenanceMode' in firstArg,
50
68
  explicitAutoCommit: firstArg.explicitAutoCommit === true || 'autoCommit' in firstArg,
51
69
  explicitSkipVersioning: firstArg.explicitSkipVersioning === true || 'skipVersioning' in firstArg,
@@ -61,6 +79,7 @@ function normalizeMainOptions(firstArg = null, secondArg = null) {
61
79
  workflowType: firstArg ?? null,
62
80
  versionArg: secondArg ?? null,
63
81
  nonInteractive: false,
82
+ thenDeploy: null,
64
83
  json: false,
65
84
  setup: false,
66
85
  presetName: null,
@@ -75,6 +94,22 @@ function normalizeMainOptions(firstArg = null, secondArg = null) {
75
94
  skipLint: false,
76
95
  skipBuild: false,
77
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,
78
113
  explicitMaintenanceMode: false,
79
114
  explicitAutoCommit: false,
80
115
  explicitSkipVersioning: false,
@@ -209,8 +244,9 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
209
244
  }
210
245
 
211
246
  if (options.workflowType === 'node' || options.workflowType === 'vue') {
212
- await releaseNode({
247
+ const releasedPackage = await releaseNode({
213
248
  releaseType: options.versionArg,
249
+ autoCommit: options.autoCommit,
214
250
  skipGitHooks: options.skipGitHooks,
215
251
  skipTests: options.skipTests,
216
252
  skipLint: options.skipLint,
@@ -219,6 +255,32 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
219
255
  skipDeploy: options.skipDeploy,
220
256
  context: appContext
221
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
+
222
284
  emitEvent?.('run_completed', {
223
285
  message: 'Zephyr workflow completed successfully.',
224
286
  data: {
@@ -240,12 +302,14 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
240
302
  if (options.workflowType === 'packagist') {
241
303
  await releasePackagist({
242
304
  releaseType: options.versionArg,
305
+ autoCommit: options.autoCommit,
243
306
  skipGitHooks: options.skipGitHooks,
244
307
  skipTests: options.skipTests,
245
308
  skipLint: options.skipLint,
246
309
  skipVersioning: options.skipVersioning,
247
310
  context: appContext
248
311
  })
312
+
249
313
  emitEvent?.('run_completed', {
250
314
  message: 'Zephyr workflow completed successfully.',
251
315
  data: {
@@ -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,