@wyxos/zephyr 0.9.12 → 0.9.14

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
@@ -52,6 +52,9 @@ zephyr minor --skip-checks
52
52
  # Deploy a configured app non-interactively
53
53
  zephyr --non-interactive --preset wyxos-release --maintenance off
54
54
 
55
+ # Deploy several configured app presets as one grouped release
56
+ zephyr --non-interactive --group "Development v1-v4" --maintenance off --skip-versioning
57
+
55
58
  # Configure a Laravel app target and verify SSH without deploying
56
59
  zephyr --setup
57
60
 
@@ -95,7 +98,7 @@ For app deployments, interactive mode now requires a real interactive terminal.
95
98
  Non-interactive mode is strict and is intended for already-configured projects:
96
99
 
97
100
  - `--non-interactive` fails instead of prompting
98
- - app deployments require `--preset <name>`
101
+ - app deployments require `--preset <name>` or `--group <name>`
99
102
  - Laravel app deployments require either a saved preset maintenance preference, `--maintenance on|off`, or a resumable snapshot that already contains the choice
100
103
  - pending deployment snapshots require either `--resume-pending` or `--discard-pending`
101
104
  - stale remote locks are never auto-removed in non-interactive mode
@@ -133,6 +136,12 @@ Recommended pattern for app deployments:
133
136
  zephyr --non-interactive --json --preset wyxos-release --maintenance off
134
137
  ```
135
138
 
139
+ Recommended pattern for grouped app deployments:
140
+
141
+ ```bash
142
+ zephyr --non-interactive --json --group "Development v1-v4" --maintenance off --skip-versioning
143
+ ```
144
+
136
145
  Recommended pattern for package releases:
137
146
 
138
147
  ```bash
@@ -270,12 +279,22 @@ Deployment targets are stored per-project at `.zephyr/config.json`:
270
279
  "sshUser": "forge",
271
280
  "sshKey": "~/.ssh/id_rsa"
272
281
  }
282
+ ],
283
+ "groups": [
284
+ {
285
+ "name": "Production targets",
286
+ "presets": [
287
+ "prod-main"
288
+ ]
289
+ }
273
290
  ]
274
291
  }
275
292
  ```
276
293
 
277
294
  Preset `options` capture repeatable deploy behavior so Zephyr can reuse the same maintenance, dirty-tree, and deploy-check preferences on later runs.
278
295
 
296
+ Deployment `groups` run the named presets in order. Zephyr performs local dependency validation once before the group starts, and after the first successful target for a branch it skips local checks and versioning for later targets on that same branch. Targets on a different branch are prepared separately.
297
+
279
298
  ### Project Directory Structure
280
299
 
281
300
  Zephyr creates a `.zephyr/` directory in your project with:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.9.12",
3
+ "version": "0.9.14",
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,68 @@
1
+ import {loadProjectConfig} from '../../config/project.mjs'
2
+ import {loadServers} from '../../config/servers.mjs'
3
+ import {ZephyrError} from '../../runtime/errors.mjs'
4
+
5
+ export function findDeploymentGroupByName(projectConfig, groupName) {
6
+ const groups = projectConfig?.groups ?? []
7
+ return groups.find((group) => group?.name === groupName) ?? null
8
+ }
9
+
10
+ export function resolveDeploymentGroupPresetNames(projectConfig, groupName) {
11
+ const group = findDeploymentGroupByName(projectConfig, groupName)
12
+
13
+ if (!group) {
14
+ throw new ZephyrError(
15
+ `Zephyr cannot run deployment group "${groupName}" because it was not found in .zephyr/config.json.`,
16
+ {code: 'ZEPHYR_DEPLOYMENT_GROUP_NOT_FOUND'}
17
+ )
18
+ }
19
+
20
+ const presetNames = Array.isArray(group.presets)
21
+ ? group.presets.filter((presetName) => typeof presetName === 'string' && presetName.trim().length > 0)
22
+ : []
23
+
24
+ if (presetNames.length === 0) {
25
+ throw new ZephyrError(
26
+ `Zephyr cannot run deployment group "${groupName}" because it has no presets.`,
27
+ {code: 'ZEPHYR_DEPLOYMENT_GROUP_INVALID'}
28
+ )
29
+ }
30
+
31
+ const configuredPresetNames = new Set((projectConfig?.presets ?? []).map((preset) => preset?.name).filter(Boolean))
32
+ const missingPresetName = presetNames.find((presetName) => !configuredPresetNames.has(presetName))
33
+
34
+ if (missingPresetName) {
35
+ throw new ZephyrError(
36
+ `Zephyr cannot run deployment group "${groupName}" because preset "${missingPresetName}" was not found in .zephyr/config.json.`,
37
+ {code: 'ZEPHYR_DEPLOYMENT_GROUP_INVALID'}
38
+ )
39
+ }
40
+
41
+ return presetNames
42
+ }
43
+
44
+ export async function resolveDeploymentGroup(rootDir, {
45
+ groupName,
46
+ logSuccess,
47
+ logWarning,
48
+ strict = true,
49
+ allowMigration = false
50
+ } = {}) {
51
+ const servers = await loadServers({
52
+ logSuccess,
53
+ logWarning,
54
+ strict,
55
+ allowMigration
56
+ })
57
+ const projectConfig = await loadProjectConfig(rootDir, servers, {
58
+ logSuccess,
59
+ logWarning,
60
+ strict,
61
+ allowMigration
62
+ })
63
+
64
+ return {
65
+ name: groupName,
66
+ presetNames: resolveDeploymentGroupPresetNames(projectConfig, groupName)
67
+ }
68
+ }
@@ -1,6 +1,6 @@
1
1
  import * as localRepo from '../../deploy/local-repo.mjs'
2
2
  import * as preflight from '../../deploy/preflight.mjs'
3
- import {commandExists} from '../../utils/command.mjs'
3
+ import {commandExists, formatCommandError} from '../../utils/command.mjs'
4
4
 
5
5
  async function getGitStatus(rootDir, {runCommandCapture} = {}) {
6
6
  return await localRepo.getGitStatus(rootDir, {runCommandCapture})
@@ -98,7 +98,7 @@ async function runLocalLaravelBuild(rootDir, {runCommand, logProcessing, logSucc
98
98
  )
99
99
  }
100
100
 
101
- throw new Error(`Local frontend build failed. Fix build failures before deploying.\n${error.message}`)
101
+ throw new Error(`Local frontend build failed. Fix build failures before deploying.\n${formatCommandError(error)}`)
102
102
  }
103
103
  }
104
104
 
@@ -106,7 +106,7 @@ async function runLocalLaravelTests(rootDir, {runCommand, logProcessing, logSucc
106
106
  logProcessing?.('Running Laravel tests locally...')
107
107
 
108
108
  try {
109
- await runCommand(testCommand.command, testCommand.args, {cwd: rootDir})
109
+ await runCommand(testCommand.command, testCommand.args, {cwd: rootDir, capture: true})
110
110
  logSuccess?.('Local tests passed.')
111
111
  } catch (error) {
112
112
  if (error.code === 'ENOENT') {
@@ -116,7 +116,7 @@ async function runLocalLaravelTests(rootDir, {runCommand, logProcessing, logSucc
116
116
  )
117
117
  }
118
118
 
119
- throw new Error(`Local tests failed. Fix test failures before deploying.\n${error.message}`)
119
+ throw new Error(`Local tests failed. Fix test failures before deploying.\n${formatCommandError(error)}`)
120
120
  }
121
121
  }
122
122
 
@@ -0,0 +1,118 @@
1
+ import {mergeDeployOptions} from '../../config/preset-options.mjs'
2
+ import {selectDeploymentTarget} from '../configuration/select-deployment-target.mjs'
3
+ import {resolvePendingSnapshot} from './resolve-pending-snapshot.mjs'
4
+ import {runDeployment} from './run-deployment.mjs'
5
+
6
+ async function runRemoteTasks(config, options = {}) {
7
+ return await runDeployment(config, {
8
+ ...options,
9
+ context: options.context
10
+ })
11
+ }
12
+
13
+ function mergePresetExecutionMode(executionMode, presetState) {
14
+ if (!presetState) {
15
+ return executionMode
16
+ }
17
+
18
+ const effectiveDeployOptions = mergeDeployOptions(executionMode, presetState.options)
19
+
20
+ return {
21
+ ...executionMode,
22
+ presetName: presetState.name,
23
+ ...effectiveDeployOptions,
24
+ skipChecks: executionMode.skipChecks === true ||
25
+ (effectiveDeployOptions.skipTests === true && effectiveDeployOptions.skipLint === true)
26
+ }
27
+ }
28
+
29
+ function reusePreparedBranchExecutionMode(executionMode, deploymentConfig, preparedBranches, logProcessing) {
30
+ if (!preparedBranches?.has(deploymentConfig.branch)) {
31
+ return executionMode
32
+ }
33
+
34
+ logProcessing?.(
35
+ `Reusing completed local preparation for branch ${deploymentConfig.branch}; skipping local checks and versioning for this target.`
36
+ )
37
+
38
+ return {
39
+ ...executionMode,
40
+ skipChecks: true,
41
+ skipTests: true,
42
+ skipLint: true,
43
+ skipVersioning: true
44
+ }
45
+ }
46
+
47
+ export async function runPresetDeployment({
48
+ rootDir,
49
+ configurationService,
50
+ appContext,
51
+ runPrompt,
52
+ logProcessing,
53
+ logSuccess,
54
+ logWarning,
55
+ emitEvent,
56
+ baseExecutionMode,
57
+ presetName,
58
+ versionArg,
59
+ preparedBranches = null
60
+ } = {}) {
61
+ let targetExecutionMode = {
62
+ ...baseExecutionMode,
63
+ presetName
64
+ }
65
+ appContext.executionMode = targetExecutionMode
66
+
67
+ const {deploymentConfig, presetState} = await selectDeploymentTarget(rootDir, {
68
+ configurationService,
69
+ runPrompt,
70
+ logProcessing,
71
+ logSuccess,
72
+ logWarning,
73
+ emitEvent,
74
+ executionMode: targetExecutionMode,
75
+ promptPresetOptions: targetExecutionMode.setup !== true
76
+ })
77
+
78
+ targetExecutionMode = mergePresetExecutionMode(targetExecutionMode, presetState)
79
+ appContext.executionMode = targetExecutionMode
80
+
81
+ if (presetState) {
82
+ await presetState.applyExecutionMode(targetExecutionMode)
83
+ }
84
+
85
+ targetExecutionMode = reusePreparedBranchExecutionMode(
86
+ targetExecutionMode,
87
+ deploymentConfig,
88
+ preparedBranches,
89
+ logProcessing
90
+ )
91
+ appContext.executionMode = targetExecutionMode
92
+
93
+ const snapshotToUse = targetExecutionMode.setup
94
+ ? null
95
+ : await resolvePendingSnapshot(rootDir, deploymentConfig, {
96
+ runPrompt,
97
+ logProcessing,
98
+ logWarning,
99
+ executionMode: targetExecutionMode
100
+ })
101
+
102
+ const deploymentOptions = {
103
+ rootDir,
104
+ snapshot: snapshotToUse,
105
+ versionArg,
106
+ context: appContext
107
+ }
108
+
109
+ if (presetState !== undefined) {
110
+ deploymentOptions.presetState = presetState
111
+ }
112
+
113
+ await runRemoteTasks(deploymentConfig, deploymentOptions)
114
+
115
+ preparedBranches?.add(deploymentConfig.branch)
116
+
117
+ return targetExecutionMode
118
+ }
@@ -42,6 +42,7 @@ export function parseCliOptions(args = process.argv.slice(2)) {
42
42
  .option('--json', 'Emit NDJSON events to stdout. Requires --non-interactive.')
43
43
  .option('--setup', 'Configure an app deployment target and verify SSH connectivity without deploying.')
44
44
  .option('--preset <name>', 'Preset name to use for non-interactive app deployments.')
45
+ .option('--group <name>', 'Deployment group name to run multiple app deployment presets in sequence.')
45
46
  .option('--resume-pending', 'Resume a saved pending deployment snapshot without prompting.')
46
47
  .option('--discard-pending', 'Discard a saved pending deployment snapshot without prompting.')
47
48
  .option('--maintenance <mode>', 'Laravel maintenance mode policy for app deployments (on|off).')
@@ -87,6 +88,7 @@ export function parseCliOptions(args = process.argv.slice(2)) {
87
88
  json: Boolean(options.json),
88
89
  setup: Boolean(options.setup),
89
90
  presetName: options.preset ?? null,
91
+ groupName: options.group ?? null,
90
92
  resumePending: Boolean(options.resumePending),
91
93
  discardPending: Boolean(options.discardPending),
92
94
  maintenanceMode: normalizeMaintenanceMode(options.maintenance),
@@ -132,6 +134,7 @@ export function validateCliOptions(options = {}) {
132
134
  setup = false,
133
135
  thenDeploy = null,
134
136
  presetName = null,
137
+ groupName = null,
135
138
  resumePending = false,
136
139
  discardPending = false,
137
140
  maintenanceMode = null,
@@ -198,6 +201,10 @@ export function validateCliOptions(options = {}) {
198
201
  throw new InvalidCliOptionsError('--preset is only valid for app deployments.')
199
202
  }
200
203
 
204
+ if (groupName) {
205
+ throw new InvalidCliOptionsError('--group is only valid for app deployments.')
206
+ }
207
+
201
208
  if (resumePending || discardPending) {
202
209
  throw new InvalidCliOptionsError('--resume-pending and --discard-pending are only valid for app deployments.')
203
210
  }
@@ -210,11 +217,19 @@ export function validateCliOptions(options = {}) {
210
217
  throw new InvalidCliOptionsError('--skip-build and --skip-deploy are only valid for node/vue release workflows.')
211
218
  }
212
219
 
220
+ if (presetName && groupName) {
221
+ throw new InvalidCliOptionsError('Use either --preset <name> or --group <name>, not both.')
222
+ }
223
+
213
224
  if (setup) {
214
225
  if (versionArg) {
215
226
  throw new InvalidCliOptionsError('--setup cannot be used with a version or bump argument.')
216
227
  }
217
228
 
229
+ if (groupName) {
230
+ throw new InvalidCliOptionsError('--setup cannot be used with --group.')
231
+ }
232
+
218
233
  if (resumePending || discardPending) {
219
234
  throw new InvalidCliOptionsError('--setup cannot be used with pending deployment snapshot flags.')
220
235
  }
@@ -232,8 +247,8 @@ export function validateCliOptions(options = {}) {
232
247
  }
233
248
  }
234
249
 
235
- if (nonInteractive && !presetName) {
236
- throw new InvalidCliOptionsError('--non-interactive app deployments require --preset <name>.')
250
+ if (nonInteractive && !presetName && !groupName) {
251
+ throw new InvalidCliOptionsError('--non-interactive app deployments require --preset <name> or --group <name>.')
237
252
  }
238
253
  }
239
254
 
@@ -105,6 +105,43 @@ export function migratePresets(presets, apps) {
105
105
  return { presets: migrated, needsMigration }
106
106
  }
107
107
 
108
+ export function migrateGroups(groups) {
109
+ if (!Array.isArray(groups)) {
110
+ return { groups: [], needsMigration: false }
111
+ }
112
+
113
+ let needsMigration = false
114
+ const migrated = groups.flatMap((group) => {
115
+ if (!group || typeof group !== 'object') {
116
+ needsMigration = true
117
+ return []
118
+ }
119
+
120
+ const name = typeof group.name === 'string' ? group.name.trim() : ''
121
+ const rawPresets = Array.isArray(group.presets)
122
+ ? group.presets
123
+ : Array.isArray(group.presetNames)
124
+ ? group.presetNames
125
+ : []
126
+ const presets = rawPresets
127
+ .map((presetName) => typeof presetName === 'string' ? presetName.trim() : '')
128
+ .filter((presetName) => presetName.length > 0)
129
+
130
+ if (!name || presets.length === 0) {
131
+ needsMigration = true
132
+ return []
133
+ }
134
+
135
+ if (name !== group.name || !Array.isArray(group.presets) || presets.length !== rawPresets.length) {
136
+ needsMigration = true
137
+ }
138
+
139
+ return [{ name, presets }]
140
+ })
141
+
142
+ return { groups: migrated, needsMigration }
143
+ }
144
+
108
145
  export async function loadProjectConfig(rootDir, servers = [], {
109
146
  logSuccess,
110
147
  logWarning,
@@ -118,11 +155,13 @@ export async function loadProjectConfig(rootDir, servers = [], {
118
155
  const data = JSON.parse(raw)
119
156
  const apps = Array.isArray(data?.apps) ? data.apps : []
120
157
  const presets = Array.isArray(data?.presets) ? data.presets : []
158
+ const groups = Array.isArray(data?.groups) ? data.groups : []
121
159
 
122
160
  const { apps: migratedApps, needsMigration: appsNeedMigration } = migrateApps(apps, servers)
123
161
  const { presets: migratedPresets, needsMigration: presetsNeedMigration } = migratePresets(presets, migratedApps)
162
+ const { groups: migratedGroups, needsMigration: groupsNeedMigration } = migrateGroups(groups)
124
163
 
125
- if (appsNeedMigration || presetsNeedMigration) {
164
+ if (appsNeedMigration || presetsNeedMigration || groupsNeedMigration) {
126
165
  if (!allowMigration) {
127
166
  throw new ZephyrError(
128
167
  'Zephyr cannot run non-interactively because .zephyr/config.json needs migration. Rerun interactively once to update the config.',
@@ -132,12 +171,13 @@ export async function loadProjectConfig(rootDir, servers = [], {
132
171
 
133
172
  await saveProjectConfig(rootDir, {
134
173
  apps: migratedApps,
135
- presets: migratedPresets
174
+ presets: migratedPresets,
175
+ groups: migratedGroups
136
176
  })
137
177
  logSuccess?.('Migrated project configuration to use unique IDs.')
138
178
  }
139
179
 
140
- return { apps: migratedApps, presets: migratedPresets }
180
+ return { apps: migratedApps, presets: migratedPresets, groups: migratedGroups }
141
181
  } catch (error) {
142
182
  if (error.code === 'ENOENT') {
143
183
  if (strict) {
@@ -147,7 +187,7 @@ export async function loadProjectConfig(rootDir, servers = [], {
147
187
  )
148
188
  }
149
189
 
150
- return { apps: [], presets: [] }
190
+ return { apps: [], presets: [], groups: [] }
151
191
  }
152
192
 
153
193
  if (error instanceof ZephyrError) {
@@ -162,19 +202,24 @@ export async function loadProjectConfig(rootDir, servers = [], {
162
202
  }
163
203
 
164
204
  logWarning?.('Failed to read .zephyr/config.json, starting with an empty list of apps.')
165
- return { apps: [], presets: [] }
205
+ return { apps: [], presets: [], groups: [] }
166
206
  }
167
207
  }
168
208
 
169
209
  export async function saveProjectConfig(rootDir, config) {
170
210
  const configDir = getProjectConfigDir(rootDir)
171
211
  await ensureDirectory(configDir)
212
+ const payloadData = {
213
+ apps: config.apps ?? [],
214
+ presets: config.presets ?? []
215
+ }
216
+
217
+ if (Array.isArray(config.groups) && config.groups.length > 0) {
218
+ payloadData.groups = config.groups
219
+ }
172
220
 
173
221
  const payload = JSON.stringify(
174
- {
175
- apps: config.apps ?? [],
176
- presets: config.presets ?? []
177
- },
222
+ payloadData,
178
223
  null,
179
224
  2
180
225
  )
@@ -1,6 +1,7 @@
1
1
  import { getCurrentBranch as getCurrentBranchImpl, getUpstreamRef as getUpstreamRefImpl } from '../utils/git.mjs'
2
2
  import {hasPrePushHook} from './preflight.mjs'
3
3
  import {gitCommitArgs, gitPushArgs} from '../utils/git-hooks.mjs'
4
+ import {formatCommandError} from '../utils/command.mjs'
4
5
  import {
5
6
  formatWorkingTreePreview,
6
7
  parseWorkingTreeEntries,
@@ -207,6 +208,13 @@ async function commitAndPushPendingChanges(targetBranch, rootDir, {
207
208
  await runCommand(command, args, { cwd })
208
209
  return undefined
209
210
  }
211
+ const runCapturedCommand = async (command, args) => {
212
+ try {
213
+ await runCommand(command, args, {cwd: rootDir, capture: true})
214
+ } catch (error) {
215
+ throw new Error(formatCommandError(error))
216
+ }
217
+ }
210
218
 
211
219
  const suggestedCommitMessage = await suggestCommitMessage(rootDir, {
212
220
  runCommand: captureAwareRunCommand,
@@ -244,10 +252,10 @@ async function commitAndPushPendingChanges(targetBranch, rootDir, {
244
252
  }
245
253
 
246
254
  logProcessing?.('Staging all pending changes before deployment...')
247
- await runCommand('git', ['add', '-A'], { cwd: rootDir })
255
+ await runCapturedCommand('git', ['add', '-A'])
248
256
 
249
257
  logProcessing?.('Committing pending changes before deployment...')
250
- await runCommand('git', gitCommitArgs(['-m', message], {skipGitHooks}), { cwd: rootDir })
258
+ await runCapturedCommand('git', gitCommitArgs(['-m', message], {skipGitHooks}))
251
259
 
252
260
  const prePushHookPresent = await hasPrePushHook(rootDir)
253
261
  if (prePushHookPresent) {
@@ -259,10 +267,10 @@ async function commitAndPushPendingChanges(targetBranch, rootDir, {
259
267
  }
260
268
 
261
269
  try {
262
- await runCommand('git', gitPushArgs(['origin', targetBranch], {skipGitHooks}), { cwd: rootDir })
270
+ await runCapturedCommand('git', gitPushArgs(['origin', targetBranch], {skipGitHooks}))
263
271
  } catch (error) {
264
272
  if (prePushHookPresent) {
265
- throw new Error(`Git push failed while the pre-push hook was running. See hook output above.\n${error.message}`)
273
+ throw new Error(`Git push failed while the pre-push hook was running.\n${error.message}`)
266
274
  }
267
275
 
268
276
  throw error
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
 
4
4
  import {gitCommitArgs} from '../utils/git-hooks.mjs'
5
+ import {formatCommandError} from '../utils/command.mjs'
5
6
 
6
7
  export async function hasPrePushHook(rootDir) {
7
8
  const hookPaths = [
@@ -137,14 +138,22 @@ export async function runLinting(rootDir, {
137
138
 
138
139
  if (selectedLintCommand.type === 'npm') {
139
140
  logProcessing?.('Running npm lint...')
140
- await runCommand(selectedLintCommand.command, selectedLintCommand.args, { cwd: rootDir })
141
+ try {
142
+ await runCommand(selectedLintCommand.command, selectedLintCommand.args, {cwd: rootDir, capture: true})
143
+ } catch (error) {
144
+ throw new Error(`Linting failed. Fix lint failures before deploying.\n${formatCommandError(error)}`)
145
+ }
141
146
  logSuccess?.('Linting completed.')
142
147
  return true
143
148
  }
144
149
 
145
150
  if (selectedLintCommand.type === 'pint') {
146
151
  logProcessing?.('Running Laravel Pint...')
147
- await runCommand(selectedLintCommand.command, selectedLintCommand.args, { cwd: rootDir })
152
+ try {
153
+ await runCommand(selectedLintCommand.command, selectedLintCommand.args, {cwd: rootDir, capture: true})
154
+ } catch (error) {
155
+ throw new Error(`Laravel Pint failed. Fix formatting failures before deploying.\n${formatCommandError(error)}`)
156
+ }
148
157
  logSuccess?.('Linting completed.')
149
158
  return true
150
159
  }
@@ -166,7 +175,7 @@ export async function runBuild(rootDir, {
166
175
  }
167
176
 
168
177
  logProcessing?.('Running local frontend build...')
169
- await runCommand(selectedBuildCommand.command, selectedBuildCommand.args, {cwd: rootDir})
178
+ await runCommand(selectedBuildCommand.command, selectedBuildCommand.args, {cwd: rootDir, capture: true})
170
179
  logSuccess?.('Local frontend build completed.')
171
180
  return true
172
181
  }
package/src/main.mjs CHANGED
@@ -11,12 +11,10 @@ import * as bootstrap from './project/bootstrap.mjs'
11
11
  import {getErrorCode, ZephyrError} from './runtime/errors.mjs'
12
12
  import {PROJECT_CONFIG_DIR} from './utils/paths.mjs'
13
13
  import {writeStderrLine} from './utils/output.mjs'
14
- import {mergeDeployOptions} from './config/preset-options.mjs'
15
14
  import {createAppContext} from './runtime/app-context.mjs'
16
15
  import {createConfigurationService} from './application/configuration/service.mjs'
17
- import {selectDeploymentTarget} from './application/configuration/select-deployment-target.mjs'
18
- import {resolvePendingSnapshot} from './application/deploy/resolve-pending-snapshot.mjs'
19
- import {runDeployment} from './application/deploy/run-deployment.mjs'
16
+ import {resolveDeploymentGroup} from './application/configuration/deployment-groups.mjs'
17
+ import {runPresetDeployment} from './application/deploy/run-preset-deployment.mjs'
20
18
  import {assertLaravelSetupProject} from './application/deploy/verify-laravel-setup.mjs'
21
19
  import {releasePackageThenDeployConsumer} from './application/consumer/release-package-then-deploy-consumer.mjs'
22
20
  import {SKIP_GIT_HOOKS_WARNING} from './utils/git-hooks.mjs'
@@ -49,6 +47,7 @@ function normalizeMainOptions(firstArg = null, secondArg = null) {
49
47
  json: firstArg.json === true,
50
48
  setup: firstArg.setup === true,
51
49
  presetName: firstArg.presetName ?? null,
50
+ groupName: firstArg.groupName ?? null,
52
51
  resumePending: firstArg.resumePending === true,
53
52
  discardPending: firstArg.discardPending === true,
54
53
  maintenanceMode: firstArg.maintenanceMode ?? null,
@@ -95,6 +94,7 @@ function normalizeMainOptions(firstArg = null, secondArg = null) {
95
94
  json: false,
96
95
  setup: false,
97
96
  presetName: null,
97
+ groupName: null,
98
98
  resumePending: false,
99
99
  discardPending: false,
100
100
  maintenanceMode: null,
@@ -162,13 +162,6 @@ function assertInteractiveAppDeploySession({workflowType = null, executionMode =
162
162
  )
163
163
  }
164
164
 
165
- async function runRemoteTasks(config, options = {}) {
166
- return await runDeployment(config, {
167
- ...options,
168
- context: options.context
169
- })
170
- }
171
-
172
165
  async function main(optionsOrWorkflowType = null, versionArg = null) {
173
166
  const options = normalizeMainOptions(optionsOrWorkflowType, versionArg)
174
167
  const rootDir = process.cwd()
@@ -179,6 +172,7 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
179
172
  workflow: resolveWorkflowName(options.workflowType),
180
173
  setup: options.setup === true,
181
174
  presetName: options.presetName,
175
+ groupName: options.groupName,
182
176
  maintenanceMode: options.maintenanceMode,
183
177
  autoCommit: options.autoCommit === true,
184
178
  skipVersioning: options.skipVersioning === true,
@@ -226,6 +220,7 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
226
220
  setup: currentExecutionMode.setup === true,
227
221
  nonInteractive: currentExecutionMode.interactive === false,
228
222
  presetName: currentExecutionMode.presetName,
223
+ groupName: currentExecutionMode.groupName,
229
224
  maintenanceMode: currentExecutionMode.maintenanceMode,
230
225
  autoCommit: currentExecutionMode.autoCommit === true,
231
226
  skipVersioning: currentExecutionMode.skipVersioning === true,
@@ -371,59 +366,65 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
371
366
  })
372
367
  }
373
368
 
374
- const {deploymentConfig, presetState} = await selectDeploymentTarget(rootDir, {
375
- configurationService,
376
- runPrompt,
377
- logProcessing,
378
- logSuccess,
379
- logWarning,
380
- emitEvent,
381
- executionMode: currentExecutionMode,
382
- promptPresetOptions: currentExecutionMode.setup !== true
383
- })
384
-
385
- if (presetState) {
386
- const effectiveDeployOptions = mergeDeployOptions(currentExecutionMode, presetState.options)
387
- currentExecutionMode = {
388
- ...currentExecutionMode,
389
- presetName: presetState.name,
390
- ...effectiveDeployOptions,
391
- skipChecks: currentExecutionMode.skipChecks === true ||
392
- (effectiveDeployOptions.skipTests === true && effectiveDeployOptions.skipLint === true)
369
+ if (currentExecutionMode.groupName) {
370
+ const deploymentGroup = await resolveDeploymentGroup(rootDir, {
371
+ groupName: currentExecutionMode.groupName,
372
+ logSuccess,
373
+ logWarning,
374
+ strict: currentExecutionMode.interactive === false,
375
+ allowMigration: currentExecutionMode.interactive !== false
376
+ })
377
+ const preparedBranches = new Set()
378
+ const baseGroupExecutionMode = {...currentExecutionMode}
379
+
380
+ logProcessing?.(`Running deployment group "${deploymentGroup.name}" with ${deploymentGroup.presetNames.length} presets.`)
381
+
382
+ for (const [index, presetName] of deploymentGroup.presetNames.entries()) {
383
+ logProcessing?.(`\nDeploying group target ${index + 1}/${deploymentGroup.presetNames.length}: ${presetName}`)
384
+ currentExecutionMode = await runPresetDeployment({
385
+ rootDir,
386
+ configurationService,
387
+ appContext,
388
+ runPrompt,
389
+ logProcessing,
390
+ logSuccess,
391
+ logWarning,
392
+ emitEvent,
393
+ baseExecutionMode: baseGroupExecutionMode,
394
+ presetName,
395
+ versionArg: options.versionArg,
396
+ preparedBranches
397
+ })
393
398
  }
394
- appContext.executionMode = currentExecutionMode
395
- await presetState.applyExecutionMode(currentExecutionMode)
396
- }
397
-
398
- const snapshotToUse = currentExecutionMode.setup
399
- ? null
400
- : await resolvePendingSnapshot(rootDir, deploymentConfig, {
399
+ } else {
400
+ currentExecutionMode = await runPresetDeployment({
401
+ rootDir,
402
+ configurationService,
403
+ appContext,
401
404
  runPrompt,
402
405
  logProcessing,
406
+ logSuccess,
403
407
  logWarning,
404
- executionMode: currentExecutionMode
408
+ emitEvent,
409
+ baseExecutionMode: currentExecutionMode,
410
+ presetName: currentExecutionMode.presetName,
411
+ versionArg: options.versionArg
405
412
  })
406
-
407
- await runRemoteTasks(deploymentConfig, {
408
- rootDir,
409
- snapshot: snapshotToUse,
410
- versionArg: options.versionArg,
411
- context: appContext,
412
- presetState
413
- })
413
+ }
414
414
 
415
415
  emitEvent?.('run_completed', {
416
416
  message: 'Zephyr workflow completed successfully.',
417
417
  data: {
418
418
  version: ZEPHYR_VERSION,
419
- workflow: currentExecutionMode.workflow
419
+ workflow: currentExecutionMode.workflow,
420
+ groupName: currentExecutionMode.groupName
420
421
  }
421
422
  })
422
423
  if (!currentExecutionMode.json) {
423
424
  await notifyWorkflowResult({
424
425
  status: 'success',
425
426
  workflow: currentExecutionMode.workflow,
426
- presetName: currentExecutionMode.presetName,
427
+ presetName: currentExecutionMode.groupName ?? currentExecutionMode.presetName,
427
428
  rootDir
428
429
  })
429
430
  }
@@ -446,7 +447,7 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
446
447
  await notifyWorkflowResult({
447
448
  status: 'failure',
448
449
  workflow: currentExecutionMode.workflow,
449
- presetName: currentExecutionMode.presetName,
450
+ presetName: currentExecutionMode.groupName ?? currentExecutionMode.presetName,
450
451
  rootDir,
451
452
  message: error.message
452
453
  })
@@ -456,4 +457,4 @@ async function main(optionsOrWorkflowType = null, versionArg = null) {
456
457
  }
457
458
  }
458
459
 
459
- export {main, runRemoteTasks}
460
+ export {main}
@@ -12,12 +12,11 @@ export function commandExists(command) {
12
12
  const resolvedCommand = resolveCommandForPlatform(command)
13
13
 
14
14
  // On Windows, use 'where', on Unix use 'which'
15
- const checker = DEFAULT_IS_WINDOWS ? 'where' : 'which'
15
+ const checker = DEFAULT_IS_WINDOWS ? 'where.exe' : 'which'
16
16
 
17
17
  try {
18
18
  const result = spawnSync(checker, [resolvedCommand], {
19
- stdio: ['ignore', 'pipe', 'ignore'],
20
- shell: DEFAULT_IS_WINDOWS
19
+ stdio: ['ignore', 'pipe', 'ignore']
21
20
  })
22
21
  return result.status === 0
23
22
  } catch {
@@ -67,7 +66,11 @@ function quoteForCmd(arg) {
67
66
  return value
68
67
  }
69
68
 
70
- export async function runCommand(command, args, { cwd = process.cwd(), stdio = 'inherit' } = {}) {
69
+ export async function runCommand(command, args, { cwd = process.cwd(), stdio = 'inherit', capture = false } = {}) {
70
+ if (capture) {
71
+ return runCommandCapture(command, args, {cwd})
72
+ }
73
+
71
74
  const resolvedCommand = resolveCommandForPlatform(command)
72
75
  const useShell = isWindowsShellShim(resolvedCommand) || shouldUseShellOnWindows(command)
73
76
 
@@ -152,3 +155,23 @@ export async function runCommandCapture(command, args, { cwd = process.cwd() } =
152
155
  })
153
156
  }
154
157
 
158
+ export function formatCapturedCommandOutput(error) {
159
+ const sections = [
160
+ ['stdout', error?.stdout],
161
+ ['stderr', error?.stderr]
162
+ ]
163
+ .map(([label, value]) => {
164
+ const text = typeof value === 'string' ? value.trim() : ''
165
+ return text ? `[${label}]\n${text}` : null
166
+ })
167
+ .filter(Boolean)
168
+
169
+ return sections.join('\n')
170
+ }
171
+
172
+ export function formatCommandError(error) {
173
+ const message = error?.message ?? String(error)
174
+ const output = formatCapturedCommandOutput(error)
175
+
176
+ return output ? `${message}\n${output}` : message
177
+ }