@wyxos/zephyr 0.3.4 → 0.4.0

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
@@ -43,6 +43,15 @@ zephyr
43
43
  # Deploy an app and bump the local npm package version first
44
44
  zephyr minor
45
45
 
46
+ # Deploy a configured app non-interactively
47
+ zephyr --non-interactive --preset wyxos-release --maintenance off
48
+
49
+ # Resume a pending non-interactive deployment
50
+ zephyr --non-interactive --preset wyxos-release --resume-pending --maintenance off
51
+
52
+ # Emit NDJSON events for automation or agent tooling
53
+ zephyr --non-interactive --json --preset wyxos-release --maintenance on
54
+
46
55
  # Release a Node/Vue package (defaults to a patch bump)
47
56
  zephyr --type node
48
57
 
@@ -55,6 +64,67 @@ zephyr --type packagist patch
55
64
 
56
65
  When `--type node` or `--type vue` is used without a bump argument, Zephyr defaults to `patch`.
57
66
 
67
+ ## Interactive and Non-Interactive Modes
68
+
69
+ Interactive mode remains the default and is the best fit for first-time setup, config repair, and one-off deployments.
70
+
71
+ Non-interactive mode is strict and is intended for already-configured projects:
72
+
73
+ - `--non-interactive` fails instead of prompting
74
+ - app deployments require `--preset <name>`
75
+ - Laravel app deployments require `--maintenance on|off` unless resuming a saved snapshot that already contains the choice
76
+ - pending deployment snapshots require either `--resume-pending` or `--discard-pending`
77
+ - stale remote locks are never auto-removed in non-interactive mode
78
+ - `--json` is only supported together with `--non-interactive`
79
+
80
+ If Zephyr would normally prompt to:
81
+
82
+ - create or repair config
83
+ - save a preset
84
+ - install the `release` script
85
+ - confirm local path dependency updates
86
+ - resolve a stale remote lock
87
+
88
+ then non-interactive mode stops immediately with a clear error instead.
89
+
90
+ ## AI Agents and Automation
91
+
92
+ Zephyr can be used safely by Codex, CI jobs, or other automation once configuration is already in place.
93
+
94
+ Recommended pattern for app deployments:
95
+
96
+ ```bash
97
+ zephyr --non-interactive --json --preset wyxos-release --maintenance off
98
+ ```
99
+
100
+ Recommended pattern for package releases:
101
+
102
+ ```bash
103
+ zephyr --type node --non-interactive --json minor
104
+ zephyr --type packagist --non-interactive --json patch
105
+ ```
106
+
107
+ In `--json` mode Zephyr emits NDJSON events on `stdout` with a stable shape:
108
+
109
+ - `run_started`
110
+ - `log`
111
+ - `prompt_required`
112
+ - `run_completed`
113
+ - `run_failed`
114
+
115
+ Each event includes:
116
+
117
+ - `event`
118
+ - `timestamp`
119
+ - `workflow`
120
+ - `message`
121
+ - `level` where relevant
122
+ - `data`
123
+
124
+ `run_failed` also includes a stable `code` field for automation checks.
125
+
126
+ In `--json` mode, Zephyr reserves `stdout` for NDJSON events and routes inherited local command output to `stderr` so agent parsers do not get corrupted.
127
+
58
128
  On a first run inside a project with `package.json`, Zephyr can:
59
129
  - add `.zephyr/` to `.gitignore`
60
130
  - add a `release` script that runs `npx @wyxos/zephyr@latest`
@@ -79,6 +149,7 @@ npm run release
79
149
  - `npm run release` is the recommended app/package entrypoint once the release script has been installed.
80
150
  - For `--type node` workflows, Zephyr runs your project's `lint` script when present.
81
151
  - For `--type node` workflows, Zephyr runs `test:run` or `test` when present.
152
+ - For non-interactive app deploys, use a saved preset name instead of relying on prompt fallback.
82
153
 
83
154
  ## Features
84
155
 
package/bin/zephyr.mjs CHANGED
@@ -1,30 +1,22 @@
1
1
  #!/usr/bin/env node
2
2
  import process from 'node:process'
3
- import { Command } from 'commander'
4
- import chalk from 'chalk'
5
- import { main } from '../src/main.mjs'
6
- import { createChalkLogger } from '../src/utils/output.mjs'
7
3
 
8
- const { logError } = createChalkLogger(chalk)
9
-
10
- const program = new Command()
11
-
12
- program
13
- .name('zephyr')
14
- .description('A streamlined deployment tool for web applications with intelligent Laravel project detection')
15
- .option('--type <type>', 'Workflow type (node|vue|packagist). Omit for normal app deployments.')
16
- .argument(
17
- '[version]',
18
- 'Version or npm bump type for deployments (e.g. 1.2.3, patch, minor, major). --type node/vue/packagist workflows accept bump types only and default to patch.'
19
- )
20
-
21
- program.parse(process.argv)
22
- const options = program.opts()
23
- const versionArg = program.args[0] ?? null
4
+ import {parseCliOptions} from '../src/cli/options.mjs'
5
+ import {main} from '../src/main.mjs'
6
+ import {writeStderrLine} from '../src/utils/output.mjs'
24
7
 
8
+ let options
25
9
  try {
26
- await main(options.type ?? null, versionArg)
10
+ options = parseCliOptions()
27
11
  } catch (error) {
28
- logError(error?.message || String(error))
29
- process.exitCode = 1
12
+ writeStderrLine(error.message)
13
+ process.exitCode = 1
14
+ }
15
+
16
+ if (options) {
17
+ try {
18
+ await main(options)
19
+ } catch {
20
+ process.exitCode = 1
21
+ }
30
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
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",
@@ -1,22 +1,82 @@
1
1
  import {writeStdoutLine} from '../../utils/output.mjs'
2
2
  import {loadServers} from '../../config/servers.mjs'
3
3
  import {loadProjectConfig, removePreset, saveProjectConfig} from '../../config/project.mjs'
4
+ import {ZephyrError} from '../../runtime/errors.mjs'
5
+
6
+ function findPresetByName(projectConfig, presetName) {
7
+ const presets = projectConfig?.presets ?? []
8
+ return presets.find((entry) => entry?.name === presetName) ?? null
9
+ }
10
+
11
+ function resolvePresetNonInteractive(projectConfig, servers, preset, presetName) {
12
+ if (!preset) {
13
+ throw new ZephyrError(
14
+ `Zephyr cannot run non-interactively because preset "${presetName}" was not found in .zephyr/config.json.`,
15
+ {code: 'ZEPHYR_PRESET_NOT_FOUND'}
16
+ )
17
+ }
18
+
19
+ if (!preset.appId) {
20
+ throw new ZephyrError(
21
+ `Zephyr cannot run non-interactively because preset "${preset.name || presetName}" uses a legacy or invalid format. Rerun interactively once to repair .zephyr/config.json.`,
22
+ {code: 'ZEPHYR_PRESET_REPAIR_REQUIRED'}
23
+ )
24
+ }
25
+
26
+ const apps = projectConfig?.apps ?? []
27
+ const appConfig = apps.find((app) => app.id === preset.appId)
28
+ if (!appConfig) {
29
+ throw new ZephyrError(
30
+ `Zephyr cannot run non-interactively because preset "${preset.name || presetName}" references an application that no longer exists.`,
31
+ {code: 'ZEPHYR_PRESET_INVALID'}
32
+ )
33
+ }
34
+
35
+ const server = servers.find((entry) => entry.id === appConfig.serverId || entry.serverName === appConfig.serverName)
36
+ if (!server) {
37
+ throw new ZephyrError(
38
+ `Zephyr cannot run non-interactively because preset "${preset.name || presetName}" references a server that no longer exists.`,
39
+ {code: 'ZEPHYR_PRESET_INVALID'}
40
+ )
41
+ }
42
+
43
+ return {
44
+ server,
45
+ appConfig,
46
+ branch: preset.branch || appConfig.branch
47
+ }
48
+ }
4
49
 
5
50
  export async function selectDeploymentTarget(rootDir, {
6
51
  configurationService,
7
52
  runPrompt,
8
53
  logProcessing,
9
54
  logSuccess,
10
- logWarning
55
+ logWarning,
56
+ emitEvent,
57
+ executionMode = {}
11
58
  } = {}) {
12
- const servers = await loadServers({logSuccess, logWarning})
13
- const projectConfig = await loadProjectConfig(rootDir, servers, {logSuccess, logWarning})
59
+ const nonInteractive = executionMode?.interactive === false
60
+ const servers = await loadServers({
61
+ logSuccess,
62
+ logWarning,
63
+ strict: nonInteractive,
64
+ allowMigration: !nonInteractive
65
+ })
66
+ const projectConfig = await loadProjectConfig(rootDir, servers, {
67
+ logSuccess,
68
+ logWarning,
69
+ strict: nonInteractive,
70
+ allowMigration: !nonInteractive
71
+ })
14
72
 
15
73
  let server = null
16
74
  let appConfig = null
17
75
  let isCreatingNewPreset = false
18
76
 
19
- const preset = await configurationService.selectPreset(projectConfig, servers)
77
+ const preset = nonInteractive
78
+ ? findPresetByName(projectConfig, executionMode.presetName)
79
+ : await configurationService.selectPreset(projectConfig, servers)
20
80
 
21
81
  const removeInvalidPreset = async () => {
22
82
  if (!preset || preset === 'create') {
@@ -34,7 +94,15 @@ export async function selectDeploymentTarget(rootDir, {
34
94
  isCreatingNewPreset = true
35
95
  }
36
96
 
37
- if (preset === 'create') {
97
+ if (nonInteractive) {
98
+ const resolved = resolvePresetNonInteractive(projectConfig, servers, preset, executionMode.presetName)
99
+ server = resolved.server
100
+ appConfig = resolved.appConfig
101
+ appConfig = {
102
+ ...appConfig,
103
+ branch: resolved.branch
104
+ }
105
+ } else if (preset === 'create') {
38
106
  isCreatingNewPreset = true
39
107
  server = await configurationService.selectServer(servers)
40
108
  appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
@@ -103,7 +171,16 @@ export async function selectDeploymentTarget(rootDir, {
103
171
  appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
104
172
  }
105
173
 
106
- const updated = await configurationService.ensureSshDetails(appConfig, rootDir)
174
+ if (nonInteractive && (!appConfig?.sshUser || !appConfig?.sshKey)) {
175
+ throw new ZephyrError(
176
+ `Zephyr cannot run non-interactively because preset "${preset?.name || executionMode.presetName}" is missing SSH details.`,
177
+ {code: 'ZEPHYR_SSH_DETAILS_REQUIRED'}
178
+ )
179
+ }
180
+
181
+ const updated = nonInteractive
182
+ ? false
183
+ : await configurationService.ensureSshDetails(appConfig, rootDir)
107
184
 
108
185
  if (updated) {
109
186
  await saveProjectConfig(rootDir, projectConfig)
@@ -119,10 +196,18 @@ export async function selectDeploymentTarget(rootDir, {
119
196
  sshKey: appConfig.sshKey
120
197
  }
121
198
 
122
- logProcessing?.('\nSelected deployment target:')
123
- writeStdoutLine(JSON.stringify(deploymentConfig, null, 2))
199
+ if (typeof emitEvent === 'function' && executionMode?.json) {
200
+ emitEvent('log', {
201
+ level: 'processing',
202
+ message: 'Selected deployment target.',
203
+ data: {deploymentConfig}
204
+ })
205
+ } else {
206
+ logProcessing?.('\nSelected deployment target:')
207
+ writeStdoutLine(JSON.stringify(deploymentConfig, null, 2))
208
+ }
124
209
 
125
- if (isCreatingNewPreset || !preset) {
210
+ if (!nonInteractive && (isCreatingNewPreset || !preset)) {
126
211
  const {presetName} = await runPrompt([
127
212
  {
128
213
  type: 'input',
@@ -1,4 +1,5 @@
1
1
  import {findPhpBinary} from '../../infrastructure/php/version.mjs'
2
+ import {ZephyrError} from '../../runtime/errors.mjs'
2
3
  import {planLaravelDeploymentTasks} from './plan-laravel-deployment-tasks.mjs'
3
4
 
4
5
  const PRERENDERED_MAINTENANCE_VIEW = 'errors::503'
@@ -241,7 +242,8 @@ function createMaintenanceModePlan({
241
242
  async function resolveMaintenanceMode({
242
243
  snapshot,
243
244
  remoteIsLaravel,
244
- runPrompt
245
+ runPrompt,
246
+ executionMode = {}
245
247
  } = {}) {
246
248
  if (!remoteIsLaravel) {
247
249
  return false
@@ -251,6 +253,17 @@ async function resolveMaintenanceMode({
251
253
  return snapshot.maintenanceModeEnabled
252
254
  }
253
255
 
256
+ if (executionMode?.interactive === false) {
257
+ if (typeof executionMode.maintenanceMode !== 'boolean') {
258
+ throw new ZephyrError(
259
+ 'Zephyr cannot run this Laravel deployment non-interactively without an explicit maintenance-mode decision. Pass --maintenance on or --maintenance off.',
260
+ {code: 'ZEPHYR_MAINTENANCE_FLAG_REQUIRED'}
261
+ )
262
+ }
263
+
264
+ return executionMode.maintenanceMode
265
+ }
266
+
254
267
  if (typeof runPrompt !== 'function') {
255
268
  return false
256
269
  }
@@ -329,6 +342,7 @@ export async function buildRemoteDeploymentPlan({
329
342
  config,
330
343
  snapshot = null,
331
344
  requiredPhpVersion = null,
345
+ executionMode = {},
332
346
  ssh,
333
347
  remoteCwd,
334
348
  executeRemote,
@@ -371,7 +385,8 @@ export async function buildRemoteDeploymentPlan({
371
385
  const maintenanceModeEnabled = await resolveMaintenanceMode({
372
386
  snapshot,
373
387
  remoteIsLaravel,
374
- runPrompt
388
+ runPrompt,
389
+ executionMode
375
390
  })
376
391
 
377
392
  const maintenanceModePlan = await resolveMaintenanceModePlan({
@@ -46,7 +46,7 @@ export async function executeRemoteDeploymentPlan({
46
46
  usefulSteps,
47
47
  pendingSnapshot = null,
48
48
  logProcessing,
49
- executionState = null
49
+ executionState = undefined
50
50
  } = {}) {
51
51
  if (usefulSteps && pendingSnapshot) {
52
52
  await persistPendingSnapshot(rootDir, pendingSnapshot, executeRemote)
@@ -73,4 +73,4 @@ export async function executeRemoteDeploymentPlan({
73
73
  await clearPendingTasksSnapshot(rootDir)
74
74
  }
75
75
  }
76
- }
76
+ }
@@ -1,9 +1,11 @@
1
1
  import {clearPendingTasksSnapshot, loadPendingTasksSnapshot} from '../../deploy/snapshots.mjs'
2
+ import {ZephyrError} from '../../runtime/errors.mjs'
2
3
 
3
4
  export async function resolvePendingSnapshot(rootDir, deploymentConfig, {
4
5
  runPrompt,
5
6
  logProcessing,
6
- logWarning
7
+ logWarning,
8
+ executionMode = {}
7
9
  } = {}) {
8
10
  const existingSnapshot = await loadPendingTasksSnapshot(rootDir)
9
11
 
@@ -25,6 +27,24 @@ export async function resolvePendingSnapshot(rootDir, deploymentConfig, {
25
27
  messageLines.push(`Tasks: ${existingSnapshot.taskLabels.join(', ')}`)
26
28
  }
27
29
 
30
+ if (executionMode?.interactive === false) {
31
+ if (!executionMode.resumePending && !executionMode.discardPending) {
32
+ throw new ZephyrError(
33
+ 'Zephyr found a pending deployment snapshot, but non-interactive mode requires either --resume-pending or --discard-pending.',
34
+ {code: 'ZEPHYR_PENDING_SNAPSHOT_ACTION_REQUIRED'}
35
+ )
36
+ }
37
+
38
+ if (executionMode.resumePending) {
39
+ logProcessing?.('Resuming deployment using saved task snapshot...')
40
+ return existingSnapshot
41
+ }
42
+
43
+ await clearPendingTasksSnapshot(rootDir)
44
+ logWarning?.('Discarded pending deployment snapshot.')
45
+ return null
46
+ }
47
+
28
48
  const {resumePendingTasks} = await runPrompt([
29
49
  {
30
50
  type: 'confirm',
@@ -26,7 +26,9 @@ async function maybeRecoverLaravelMaintenanceMode({
26
26
  executionState,
27
27
  executeRemote,
28
28
  runPrompt,
29
- logWarning
29
+ logProcessing,
30
+ logWarning,
31
+ executionMode = {}
30
32
  } = {}) {
31
33
  if (!remotePlan?.remoteIsLaravel || !remotePlan?.maintenanceModeEnabled) {
32
34
  return
@@ -36,12 +38,27 @@ async function maybeRecoverLaravelMaintenanceMode({
36
38
  return
37
39
  }
38
40
 
39
- if (typeof runPrompt !== 'function' || typeof executeRemote !== 'function') {
41
+ if (typeof executeRemote !== 'function') {
40
42
  logWarning?.('Deployment failed while Laravel maintenance mode may still be enabled.')
41
43
  return
42
44
  }
43
45
 
44
46
  try {
47
+ if (executionMode?.interactive === false) {
48
+ logProcessing?.('Deployment failed after Laravel maintenance mode was enabled. Running `artisan up` automatically...')
49
+ await executeRemote(
50
+ 'Disable Laravel maintenance mode',
51
+ remotePlan.maintenanceUpCommand ?? `${remotePlan.phpCommand} artisan up`
52
+ )
53
+ executionState.exitedMaintenanceMode = true
54
+ return
55
+ }
56
+
57
+ if (typeof runPrompt !== 'function') {
58
+ logWarning?.('Deployment failed while Laravel maintenance mode may still be enabled.')
59
+ return
60
+ }
61
+
45
62
  const answers = await runPrompt([
46
63
  {
47
64
  type: 'confirm',
@@ -82,7 +99,8 @@ export async function runDeployment(config, options = {}) {
82
99
  logError,
83
100
  runPrompt,
84
101
  createSshClient,
85
- runCommand
102
+ runCommand,
103
+ executionMode
86
104
  } = context
87
105
 
88
106
  await cleanupOldLogs(rootDir)
@@ -126,7 +144,11 @@ export async function runDeployment(config, options = {}) {
126
144
  remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
127
145
 
128
146
  logProcessing('Connection established. Acquiring deployment lock on server...')
129
- await acquireRemoteLock(ssh, remoteCwd, rootDir, {runPrompt, logWarning})
147
+ await acquireRemoteLock(ssh, remoteCwd, rootDir, {
148
+ runPrompt,
149
+ logWarning,
150
+ interactive: executionMode?.interactive !== false
151
+ })
130
152
  lockAcquired = true
131
153
  logProcessing(`Lock acquired. Running deployment commands in ${remoteCwd}...`)
132
154
 
@@ -145,6 +167,7 @@ export async function runDeployment(config, options = {}) {
145
167
  snapshot,
146
168
  rootDir,
147
169
  requiredPhpVersion,
170
+ executionMode,
148
171
  ssh,
149
172
  remoteCwd,
150
173
  executeRemote,
@@ -179,12 +202,18 @@ export async function runDeployment(config, options = {}) {
179
202
  executionState,
180
203
  executeRemote,
181
204
  runPrompt,
182
- logWarning
205
+ logProcessing,
206
+ logWarning,
207
+ executionMode
183
208
  })
184
209
 
185
210
  if (lockAcquired && ssh && remoteCwd) {
186
211
  try {
187
- await compareLocksAndPrompt(rootDir, ssh, remoteCwd, {runPrompt, logWarning})
212
+ await compareLocksAndPrompt(rootDir, ssh, remoteCwd, {
213
+ runPrompt,
214
+ logWarning,
215
+ interactive: executionMode?.interactive !== false
216
+ })
188
217
  } catch {
189
218
  // Ignore lock comparison errors during error handling
190
219
  }
@@ -206,4 +235,4 @@ export async function runDeployment(config, options = {}) {
206
235
  ssh.dispose()
207
236
  }
208
237
  }
209
- }
238
+ }
@@ -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}.`)