@wyxos/zephyr 0.3.4 → 0.4.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.
@@ -12,7 +12,7 @@ import {createRemoteExecutor} from '../../deploy/remote-exec.mjs'
12
12
  import {resolveSshKeyPath} from '../../ssh/keys.mjs'
13
13
  import {cleanupOldLogs, closeLogFile, getLogFilePath, writeToLogFile} from '../../utils/log-file.mjs'
14
14
  import {resolveRemotePath} from '../../utils/remote-path.mjs'
15
- import {buildRemoteDeploymentPlan} from './build-remote-deployment-plan.mjs'
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
18
 
@@ -21,12 +21,45 @@ async function resolveRemoteHome(ssh, sshUser) {
21
21
  return remoteHomeResult.stdout.trim() || `/home/${sshUser}`
22
22
  }
23
23
 
24
+ async function connectToRemoteDeploymentTarget({
25
+ config,
26
+ createSshClient,
27
+ sshUser,
28
+ privateKey,
29
+ remoteCwd = null,
30
+ logProcessing,
31
+ message
32
+ } = {}) {
33
+ const ssh = createSshClient()
34
+
35
+ logProcessing?.(`\n${message ?? `Connecting to ${config.serverIp} as ${sshUser}...`}`)
36
+
37
+ await ssh.connect({
38
+ host: config.serverIp,
39
+ username: sshUser,
40
+ privateKey
41
+ })
42
+
43
+ if (remoteCwd) {
44
+ return {ssh, remoteCwd}
45
+ }
46
+
47
+ const remoteHome = await resolveRemoteHome(ssh, sshUser)
48
+
49
+ return {
50
+ ssh,
51
+ remoteCwd: resolveRemotePath(config.projectPath, remoteHome)
52
+ }
53
+ }
54
+
24
55
  async function maybeRecoverLaravelMaintenanceMode({
25
56
  remotePlan,
26
57
  executionState,
27
58
  executeRemote,
28
59
  runPrompt,
29
- logWarning
60
+ logProcessing,
61
+ logWarning,
62
+ executionMode = {}
30
63
  } = {}) {
31
64
  if (!remotePlan?.remoteIsLaravel || !remotePlan?.maintenanceModeEnabled) {
32
65
  return
@@ -36,12 +69,27 @@ async function maybeRecoverLaravelMaintenanceMode({
36
69
  return
37
70
  }
38
71
 
39
- if (typeof runPrompt !== 'function' || typeof executeRemote !== 'function') {
72
+ if (typeof executeRemote !== 'function') {
40
73
  logWarning?.('Deployment failed while Laravel maintenance mode may still be enabled.')
41
74
  return
42
75
  }
43
76
 
44
77
  try {
78
+ if (executionMode?.interactive === false) {
79
+ logProcessing?.('Deployment failed after Laravel maintenance mode was enabled. Running `artisan up` automatically...')
80
+ await executeRemote(
81
+ 'Disable Laravel maintenance mode',
82
+ remotePlan.maintenanceUpCommand ?? `${remotePlan.phpCommand} artisan up`
83
+ )
84
+ executionState.exitedMaintenanceMode = true
85
+ return
86
+ }
87
+
88
+ if (typeof runPrompt !== 'function') {
89
+ logWarning?.('Deployment failed while Laravel maintenance mode may still be enabled.')
90
+ return
91
+ }
92
+
45
93
  const answers = await runPrompt([
46
94
  {
47
95
  type: 'confirm',
@@ -82,51 +130,79 @@ export async function runDeployment(config, options = {}) {
82
130
  logError,
83
131
  runPrompt,
84
132
  createSshClient,
85
- runCommand
133
+ runCommand,
134
+ executionMode
86
135
  } = context
87
136
 
88
137
  await cleanupOldLogs(rootDir)
89
138
 
90
- const {requiredPhpVersion} = await prepareLocalDeployment(config, {
91
- snapshot,
92
- rootDir,
93
- versionArg,
94
- runPrompt,
95
- runCommand,
96
- runCommandCapture: context.runCommandCapture,
97
- logProcessing,
98
- logSuccess,
99
- logWarning
100
- })
101
-
102
- const ssh = createSshClient()
103
139
  const sshUser = config.sshUser || os.userInfo().username
104
140
  const privateKeyPath = await resolveSshKeyPath(config.sshKey)
105
141
  const privateKey = await fs.readFile(privateKeyPath, 'utf8')
142
+ let ssh = null
106
143
  let remoteCwd = null
107
144
  let executeRemote = null
108
145
  let remotePlan = null
146
+ let remoteState = null
109
147
  const executionState = {
110
148
  enteredMaintenanceMode: false,
111
149
  exitedMaintenanceMode: false
112
150
  }
113
151
 
114
- logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
115
-
116
152
  let lockAcquired = false
117
153
 
118
154
  try {
119
- await ssh.connect({
120
- host: config.serverIp,
121
- username: sshUser,
122
- privateKey
155
+ ({ssh, remoteCwd} = await connectToRemoteDeploymentTarget({
156
+ config,
157
+ createSshClient,
158
+ sshUser,
159
+ privateKey,
160
+ logProcessing,
161
+ message: `Connecting to ${config.serverIp} as ${sshUser} to inspect remote deployment state...`
162
+ }))
163
+
164
+ remoteState = await resolveRemoteDeploymentState({
165
+ snapshot,
166
+ executionMode,
167
+ ssh,
168
+ remoteCwd,
169
+ runPrompt,
170
+ logSuccess,
171
+ logWarning
172
+ })
173
+
174
+ // Reconnect after local checks so long-running validation does not hold a stale SSH session open.
175
+ await ssh.dispose()
176
+ ssh = null
177
+
178
+ const {requiredPhpVersion} = await prepareLocalDeployment(config, {
179
+ snapshot,
180
+ rootDir,
181
+ versionArg,
182
+ runPrompt,
183
+ runCommand,
184
+ runCommandCapture: context.runCommandCapture,
185
+ logProcessing,
186
+ logSuccess,
187
+ logWarning
123
188
  })
124
189
 
125
- const remoteHome = await resolveRemoteHome(ssh, sshUser)
126
- remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
190
+ ;({ssh} = await connectToRemoteDeploymentTarget({
191
+ config,
192
+ createSshClient,
193
+ sshUser,
194
+ privateKey,
195
+ remoteCwd,
196
+ logProcessing,
197
+ message: `Reconnecting to ${config.serverIp} as ${sshUser}...`
198
+ }))
127
199
 
128
200
  logProcessing('Connection established. Acquiring deployment lock on server...')
129
- await acquireRemoteLock(ssh, remoteCwd, rootDir, {runPrompt, logWarning})
201
+ await acquireRemoteLock(ssh, remoteCwd, rootDir, {
202
+ runPrompt,
203
+ logWarning,
204
+ interactive: executionMode?.interactive !== false
205
+ })
130
206
  lockAcquired = true
131
207
  logProcessing(`Lock acquired. Running deployment commands in ${remoteCwd}...`)
132
208
 
@@ -145,6 +221,9 @@ export async function runDeployment(config, options = {}) {
145
221
  snapshot,
146
222
  rootDir,
147
223
  requiredPhpVersion,
224
+ executionMode,
225
+ remoteIsLaravel: remoteState?.remoteIsLaravel,
226
+ maintenanceModeEnabled: remoteState?.maintenanceModeEnabled,
148
227
  ssh,
149
228
  remoteCwd,
150
229
  executeRemote,
@@ -179,12 +258,18 @@ export async function runDeployment(config, options = {}) {
179
258
  executionState,
180
259
  executeRemote,
181
260
  runPrompt,
182
- logWarning
261
+ logProcessing,
262
+ logWarning,
263
+ executionMode
183
264
  })
184
265
 
185
266
  if (lockAcquired && ssh && remoteCwd) {
186
267
  try {
187
- await compareLocksAndPrompt(rootDir, ssh, remoteCwd, {runPrompt, logWarning})
268
+ await compareLocksAndPrompt(rootDir, ssh, remoteCwd, {
269
+ runPrompt,
270
+ logWarning,
271
+ interactive: executionMode?.interactive !== false
272
+ })
188
273
  } catch {
189
274
  // Ignore lock comparison errors during error handling
190
275
  }
@@ -206,4 +291,4 @@ export async function runDeployment(config, options = {}) {
206
291
  ssh.dispose()
207
292
  }
208
293
  }
209
- }
294
+ }
@@ -8,7 +8,7 @@ import {writeStderr} from '../../utils/output.mjs'
8
8
  import {
9
9
  ensureCleanWorkingTree,
10
10
  ensureReleaseBranchReady,
11
- runReleaseCommand as runCommand,
11
+ runReleaseCommand,
12
12
  validateReleaseDependencies
13
13
  } from '../../release/shared.mjs'
14
14
 
@@ -22,7 +22,12 @@ function hasScript(pkg, scriptName) {
22
22
  return pkg?.scripts?.[scriptName] !== undefined
23
23
  }
24
24
 
25
- async function runLint(skipLint, pkg, rootDir = process.cwd(), {logStep, logSuccess, logWarning} = {}) {
25
+ async function runLint(skipLint, pkg, rootDir = process.cwd(), {
26
+ logStep,
27
+ logSuccess,
28
+ logWarning,
29
+ runCommand = runReleaseCommand
30
+ } = {}) {
26
31
  if (skipLint) {
27
32
  logWarning?.('Skipping lint because --skip-lint flag was provided.')
28
33
  return
@@ -49,7 +54,12 @@ async function runLint(skipLint, pkg, rootDir = process.cwd(), {logStep, logSucc
49
54
  }
50
55
  }
51
56
 
52
- async function runTests(skipTests, pkg, rootDir = process.cwd(), {logStep, logSuccess, logWarning} = {}) {
57
+ async function runTests(skipTests, pkg, rootDir = process.cwd(), {
58
+ logStep,
59
+ logSuccess,
60
+ logWarning,
61
+ runCommand = runReleaseCommand
62
+ } = {}) {
53
63
  if (skipTests) {
54
64
  logWarning?.('Skipping tests because --skip-tests flag was provided.')
55
65
  return
@@ -91,7 +101,12 @@ async function runTests(skipTests, pkg, rootDir = process.cwd(), {logStep, logSu
91
101
  }
92
102
  }
93
103
 
94
- async function runBuild(skipBuild, pkg, rootDir = process.cwd(), {logStep, logSuccess, logWarning} = {}) {
104
+ async function runBuild(skipBuild, pkg, rootDir = process.cwd(), {
105
+ logStep,
106
+ logSuccess,
107
+ logWarning,
108
+ runCommand = runReleaseCommand
109
+ } = {}) {
95
110
  if (skipBuild) {
96
111
  logWarning?.('Skipping build because --skip-build flag was provided.')
97
112
  return
@@ -118,7 +133,12 @@ async function runBuild(skipBuild, pkg, rootDir = process.cwd(), {logStep, logSu
118
133
  }
119
134
  }
120
135
 
121
- async function runLibBuild(skipBuild, pkg, rootDir = process.cwd(), {logStep, logSuccess, logWarning} = {}) {
136
+ async function runLibBuild(skipBuild, pkg, rootDir = process.cwd(), {
137
+ logStep,
138
+ logSuccess,
139
+ logWarning,
140
+ runCommand = runReleaseCommand
141
+ } = {}) {
122
142
  if (skipBuild) {
123
143
  logWarning?.('Skipping library build because --skip-build flag was provided.')
124
144
  return false
@@ -160,7 +180,11 @@ async function runLibBuild(skipBuild, pkg, rootDir = process.cwd(), {logStep, lo
160
180
  return hasLibChanges
161
181
  }
162
182
 
163
- async function bumpVersion(releaseType, rootDir = process.cwd(), {logStep, logSuccess} = {}) {
183
+ async function bumpVersion(releaseType, rootDir = process.cwd(), {
184
+ logStep,
185
+ logSuccess,
186
+ runCommand = runReleaseCommand
187
+ } = {}) {
164
188
  logStep?.('Bumping package version...')
165
189
 
166
190
  const {stdout: statusBefore} = await runCommand('git', ['status', '--porcelain'], {capture: true, cwd: rootDir})
@@ -197,7 +221,11 @@ async function bumpVersion(releaseType, rootDir = process.cwd(), {logStep, logSu
197
221
  return pkg
198
222
  }
199
223
 
200
- async function pushChanges(rootDir = process.cwd(), {logStep, logSuccess} = {}) {
224
+ async function pushChanges(rootDir = process.cwd(), {
225
+ logStep,
226
+ logSuccess,
227
+ runCommand = runReleaseCommand
228
+ } = {}) {
201
229
  logStep?.('Pushing commits and tags to origin...')
202
230
  try {
203
231
  await runCommand('git', ['push', '--follow-tags'], {capture: true, cwd: rootDir})
@@ -224,7 +252,12 @@ function extractDomainFromHomepage(homepage) {
224
252
  }
225
253
  }
226
254
 
227
- async function deployGHPages(skipDeploy, pkg, rootDir = process.cwd(), {logStep, logSuccess, logWarning} = {}) {
255
+ async function deployGHPages(skipDeploy, pkg, rootDir = process.cwd(), {
256
+ logStep,
257
+ logSuccess,
258
+ logWarning,
259
+ runCommand = runReleaseCommand
260
+ } = {}) {
228
261
  if (skipDeploy) {
229
262
  logWarning?.('Skipping GitHub Pages deployment because --skip-deploy flag was provided.')
230
263
  return
@@ -315,26 +348,40 @@ export async function releaseNodePackage({
315
348
  rootDir = process.cwd(),
316
349
  logStep,
317
350
  logSuccess,
318
- logWarning
351
+ logWarning,
352
+ runPrompt,
353
+ runCommandImpl,
354
+ runCommandCaptureImpl,
355
+ interactive = true
319
356
  } = {}) {
357
+ const runCommand = (command, args, options = {}) => runReleaseCommand(command, args, {
358
+ ...options,
359
+ runCommandImpl,
360
+ runCommandCaptureImpl
361
+ })
362
+
320
363
  logStep?.('Reading package metadata...')
321
364
  const pkg = await readPackage(rootDir)
322
365
 
323
366
  logStep?.('Validating dependencies...')
324
- await validateReleaseDependencies(rootDir, {logSuccess})
367
+ await validateReleaseDependencies(rootDir, {
368
+ prompt: runPrompt,
369
+ logSuccess,
370
+ interactive
371
+ })
325
372
 
326
373
  logStep?.('Checking working tree status...')
327
374
  await ensureCleanWorkingTree(rootDir, {runCommand})
328
375
  await ensureReleaseBranchReady({rootDir, branchMethod: 'show-current', logStep, logWarning})
329
376
 
330
- await runLint(skipLint, pkg, rootDir, {logStep, logSuccess, logWarning})
331
- await runTests(skipTests, pkg, rootDir, {logStep, logSuccess, logWarning})
332
- await runLibBuild(skipBuild, pkg, rootDir, {logStep, logSuccess, logWarning})
377
+ await runLint(skipLint, pkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
378
+ await runTests(skipTests, pkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
379
+ await runLibBuild(skipBuild, pkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
333
380
 
334
- const updatedPkg = await bumpVersion(releaseType, rootDir, {logStep, logSuccess})
335
- await runBuild(skipBuild, updatedPkg, rootDir, {logStep, logSuccess, logWarning})
336
- await pushChanges(rootDir, {logStep, logSuccess})
337
- await deployGHPages(skipDeploy, updatedPkg, rootDir, {logStep, logSuccess, logWarning})
381
+ const updatedPkg = await bumpVersion(releaseType, rootDir, {logStep, logSuccess, runCommand})
382
+ await runBuild(skipBuild, updatedPkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
383
+ await pushChanges(rootDir, {logStep, logSuccess, runCommand})
384
+ await deployGHPages(skipDeploy, updatedPkg, rootDir, {logStep, logSuccess, logWarning, runCommand})
338
385
 
339
386
  logStep?.('Publishing will be handled by GitHub Actions via trusted publishing.')
340
387
  logSuccess?.(`Release workflow completed for ${updatedPkg.name}@${updatedPkg.version}.`)
@@ -8,7 +8,7 @@ import {writeStderr} from '../../utils/output.mjs'
8
8
  import {
9
9
  ensureCleanWorkingTree,
10
10
  ensureReleaseBranchReady,
11
- runReleaseCommand as runCommand,
11
+ runReleaseCommand,
12
12
  validateReleaseDependencies
13
13
  } from '../../release/shared.mjs'
14
14
 
@@ -50,7 +50,13 @@ async function hasArtisan(rootDir = process.cwd()) {
50
50
  }
51
51
  }
52
52
 
53
- async function runLint(skipLint, rootDir = process.cwd(), {logStep, logSuccess, logWarning} = {}) {
53
+ async function runLint(skipLint, rootDir = process.cwd(), {
54
+ logStep,
55
+ logSuccess,
56
+ logWarning,
57
+ runCommand = runReleaseCommand,
58
+ progressWriter = process.stdout
59
+ } = {}) {
54
60
  if (skipLint) {
55
61
  logWarning?.('Skipping lint because --skip-lint flag was provided.')
56
62
  return
@@ -67,9 +73,9 @@ async function runLint(skipLint, rootDir = process.cwd(), {logStep, logSuccess,
67
73
 
68
74
  let dotInterval = null
69
75
  try {
70
- process.stdout.write(' ')
76
+ progressWriter.write(' ')
71
77
  dotInterval = setInterval(() => {
72
- process.stdout.write('.')
78
+ progressWriter.write('.')
73
79
  }, 200)
74
80
 
75
81
  await runCommand('php', [pintPath], {capture: true, cwd: rootDir})
@@ -78,14 +84,14 @@ async function runLint(skipLint, rootDir = process.cwd(), {logStep, logSuccess,
78
84
  clearInterval(dotInterval)
79
85
  dotInterval = null
80
86
  }
81
- process.stdout.write('\n')
87
+ progressWriter.write('\n')
82
88
  logSuccess?.('Lint passed.')
83
89
  } catch (error) {
84
90
  if (dotInterval) {
85
91
  clearInterval(dotInterval)
86
92
  dotInterval = null
87
93
  }
88
- process.stdout.write('\n')
94
+ progressWriter.write('\n')
89
95
  if (error.stdout) {
90
96
  writeStderr(error.stdout)
91
97
  }
@@ -96,7 +102,13 @@ async function runLint(skipLint, rootDir = process.cwd(), {logStep, logSuccess,
96
102
  }
97
103
  }
98
104
 
99
- async function runTests(skipTests, composer, rootDir = process.cwd(), {logStep, logSuccess, logWarning} = {}) {
105
+ async function runTests(skipTests, composer, rootDir = process.cwd(), {
106
+ logStep,
107
+ logSuccess,
108
+ logWarning,
109
+ runCommand = runReleaseCommand,
110
+ progressWriter = process.stdout
111
+ } = {}) {
100
112
  if (skipTests) {
101
113
  logWarning?.('Skipping tests because --skip-tests flag was provided.')
102
114
  return
@@ -114,9 +126,9 @@ async function runTests(skipTests, composer, rootDir = process.cwd(), {logStep,
114
126
 
115
127
  let dotInterval = null
116
128
  try {
117
- process.stdout.write(' ')
129
+ progressWriter.write(' ')
118
130
  dotInterval = setInterval(() => {
119
- process.stdout.write('.')
131
+ progressWriter.write('.')
120
132
  }, 200)
121
133
 
122
134
  if (hasArtisanFile) {
@@ -129,14 +141,14 @@ async function runTests(skipTests, composer, rootDir = process.cwd(), {logStep,
129
141
  clearInterval(dotInterval)
130
142
  dotInterval = null
131
143
  }
132
- process.stdout.write('\n')
144
+ progressWriter.write('\n')
133
145
  logSuccess?.('Tests passed.')
134
146
  } catch (error) {
135
147
  if (dotInterval) {
136
148
  clearInterval(dotInterval)
137
149
  dotInterval = null
138
150
  }
139
- process.stdout.write('\n')
151
+ progressWriter.write('\n')
140
152
  if (error.stdout) {
141
153
  writeStderr(error.stdout)
142
154
  }
@@ -147,7 +159,11 @@ async function runTests(skipTests, composer, rootDir = process.cwd(), {logStep,
147
159
  }
148
160
  }
149
161
 
150
- async function bumpVersion(releaseType, rootDir = process.cwd(), {logStep, logSuccess} = {}) {
162
+ async function bumpVersion(releaseType, rootDir = process.cwd(), {
163
+ logStep,
164
+ logSuccess,
165
+ runCommand = runReleaseCommand
166
+ } = {}) {
151
167
  logStep?.('Bumping composer version...')
152
168
 
153
169
  const composer = await readComposer(rootDir)
@@ -179,7 +195,11 @@ async function bumpVersion(releaseType, rootDir = process.cwd(), {logStep, logSu
179
195
  return {...composer, version: newVersion}
180
196
  }
181
197
 
182
- async function pushChanges(rootDir = process.cwd(), {logStep, logSuccess} = {}) {
198
+ async function pushChanges(rootDir = process.cwd(), {
199
+ logStep,
200
+ logSuccess,
201
+ runCommand = runReleaseCommand
202
+ } = {}) {
183
203
  logStep?.('Pushing commits to origin...')
184
204
  await runCommand('git', ['push'], {cwd: rootDir})
185
205
 
@@ -196,8 +216,19 @@ export async function releasePackagistPackage({
196
216
  rootDir = process.cwd(),
197
217
  logStep,
198
218
  logSuccess,
199
- logWarning
219
+ logWarning,
220
+ runPrompt,
221
+ runCommandImpl,
222
+ runCommandCaptureImpl,
223
+ interactive = true,
224
+ progressWriter = process.stdout
200
225
  } = {}) {
226
+ const runCommand = (command, args, options = {}) => runReleaseCommand(command, args, {
227
+ ...options,
228
+ runCommandImpl,
229
+ runCommandCaptureImpl
230
+ })
231
+
201
232
  logStep?.('Reading composer metadata...')
202
233
  const composer = await readComposer(rootDir)
203
234
 
@@ -206,17 +237,21 @@ export async function releasePackagistPackage({
206
237
  }
207
238
 
208
239
  logStep?.('Validating dependencies...')
209
- await validateReleaseDependencies(rootDir, {logSuccess})
240
+ await validateReleaseDependencies(rootDir, {
241
+ prompt: runPrompt,
242
+ logSuccess,
243
+ interactive
244
+ })
210
245
 
211
246
  logStep?.('Checking working tree status...')
212
247
  await ensureCleanWorkingTree(rootDir, {runCommand})
213
248
  await ensureReleaseBranchReady({rootDir, branchMethod: 'show-current', logStep, logWarning})
214
249
 
215
- await runLint(skipLint, rootDir, {logStep, logSuccess, logWarning})
216
- await runTests(skipTests, composer, rootDir, {logStep, logSuccess, logWarning})
250
+ await runLint(skipLint, rootDir, {logStep, logSuccess, logWarning, runCommand, progressWriter})
251
+ await runTests(skipTests, composer, rootDir, {logStep, logSuccess, logWarning, runCommand, progressWriter})
217
252
 
218
- const updatedComposer = await bumpVersion(releaseType, rootDir, {logStep, logSuccess})
219
- await pushChanges(rootDir, {logStep, logSuccess})
253
+ const updatedComposer = await bumpVersion(releaseType, rootDir, {logStep, logSuccess, runCommand})
254
+ await pushChanges(rootDir, {logStep, logSuccess, runCommand})
220
255
 
221
256
  logSuccess?.(`Release workflow completed for ${composer.name}@${updatedComposer.version}.`)
222
257
  logStep?.('Note: Packagist will automatically detect the new git tag and update the package.')
@@ -0,0 +1,122 @@
1
+ import process from 'node:process'
2
+
3
+ import {Command} from 'commander'
4
+
5
+ import {InvalidCliOptionsError} from '../runtime/errors.mjs'
6
+
7
+ const WORKFLOW_TYPES = new Set(['node', 'vue', 'packagist'])
8
+ function normalizeMaintenanceMode(value) {
9
+ if (value == null) {
10
+ return null
11
+ }
12
+
13
+ if (value === 'on') {
14
+ return true
15
+ }
16
+
17
+ if (value === 'off') {
18
+ return false
19
+ }
20
+
21
+ throw new InvalidCliOptionsError('Invalid value for --maintenance. Use "on" or "off".')
22
+ }
23
+
24
+ export function parseCliOptions(args = process.argv.slice(2)) {
25
+ const program = new Command()
26
+
27
+ program
28
+ .allowExcessArguments(false)
29
+ .allowUnknownOption(false)
30
+ .exitOverride()
31
+ .option('--type <type>', 'Workflow type (node|vue|packagist). Omit for normal app deployments.')
32
+ .option('--non-interactive', 'Fail instead of prompting when Zephyr needs user input.')
33
+ .option('--json', 'Emit NDJSON events to stdout. Requires --non-interactive.')
34
+ .option('--preset <name>', 'Preset name to use for non-interactive app deployments.')
35
+ .option('--resume-pending', 'Resume a saved pending deployment snapshot without prompting.')
36
+ .option('--discard-pending', 'Discard a saved pending deployment snapshot without prompting.')
37
+ .option('--maintenance <mode>', 'Laravel maintenance mode policy for non-interactive app deploys (on|off).')
38
+ .option('--skip-tests', 'Skip test execution in package release workflows.')
39
+ .option('--skip-lint', 'Skip lint execution in package release workflows.')
40
+ .option('--skip-build', 'Skip build execution in node/vue release workflows.')
41
+ .option('--skip-deploy', 'Skip GitHub Pages deployment in node/vue release workflows.')
42
+ .argument(
43
+ '[version]',
44
+ 'Version or npm bump type for deployments (e.g. 1.2.3, patch, minor, major).'
45
+ )
46
+
47
+ try {
48
+ program.parse(args, {from: 'user'})
49
+ } catch (error) {
50
+ throw new InvalidCliOptionsError(error.message)
51
+ }
52
+
53
+ const options = program.opts()
54
+ const workflowType = options.type ?? null
55
+
56
+ if (workflowType && !WORKFLOW_TYPES.has(workflowType)) {
57
+ throw new InvalidCliOptionsError('Invalid value for --type. Use one of: node, vue, packagist.')
58
+ }
59
+
60
+ return {
61
+ workflowType,
62
+ versionArg: program.args[0] ?? null,
63
+ nonInteractive: Boolean(options.nonInteractive),
64
+ json: Boolean(options.json),
65
+ presetName: options.preset ?? null,
66
+ resumePending: Boolean(options.resumePending),
67
+ discardPending: Boolean(options.discardPending),
68
+ maintenanceMode: normalizeMaintenanceMode(options.maintenance),
69
+ skipTests: Boolean(options.skipTests),
70
+ skipLint: Boolean(options.skipLint),
71
+ skipBuild: Boolean(options.skipBuild),
72
+ skipDeploy: Boolean(options.skipDeploy)
73
+ }
74
+ }
75
+
76
+ export function validateCliOptions(options = {}) {
77
+ const {
78
+ workflowType = null,
79
+ nonInteractive = false,
80
+ json = false,
81
+ presetName = null,
82
+ resumePending = false,
83
+ discardPending = false,
84
+ maintenanceMode = null,
85
+ skipTests = false,
86
+ skipLint = false,
87
+ skipBuild = false,
88
+ skipDeploy = false
89
+ } = options
90
+
91
+ if (json && !nonInteractive) {
92
+ throw new InvalidCliOptionsError('--json requires --non-interactive.')
93
+ }
94
+
95
+ if (resumePending && discardPending) {
96
+ throw new InvalidCliOptionsError('Use either --resume-pending or --discard-pending, not both.')
97
+ }
98
+
99
+ const isPackageRelease = workflowType === 'node' || workflowType === 'vue' || workflowType === 'packagist'
100
+
101
+ if (isPackageRelease) {
102
+ if (presetName) {
103
+ throw new InvalidCliOptionsError('--preset is only valid for app deployments.')
104
+ }
105
+
106
+ if (resumePending || discardPending) {
107
+ throw new InvalidCliOptionsError('--resume-pending and --discard-pending are only valid for app deployments.')
108
+ }
109
+
110
+ if (maintenanceMode !== null) {
111
+ throw new InvalidCliOptionsError('--maintenance is only valid for app deployments.')
112
+ }
113
+ } else {
114
+ if (skipTests || skipLint || skipBuild || skipDeploy) {
115
+ throw new InvalidCliOptionsError('Release-only skip flags are not valid for app deployments.')
116
+ }
117
+
118
+ if (nonInteractive && !presetName) {
119
+ throw new InvalidCliOptionsError('--non-interactive app deployments require --preset <name>.')
120
+ }
121
+ }
122
+ }