@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.
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs/promises'
2
2
 
3
+ import {ZephyrError} from '../runtime/errors.mjs'
3
4
  import { ensureDirectory, getProjectConfigDir, getProjectConfigPath } from '../utils/paths.mjs'
4
5
  import { generateId } from '../utils/id.mjs'
5
6
 
@@ -69,7 +70,12 @@ export function migratePresets(presets, apps) {
69
70
  return { presets: migrated, needsMigration }
70
71
  }
71
72
 
72
- export async function loadProjectConfig(rootDir, servers = [], { logSuccess, logWarning } = {}) {
73
+ export async function loadProjectConfig(rootDir, servers = [], {
74
+ logSuccess,
75
+ logWarning,
76
+ strict = false,
77
+ allowMigration = true
78
+ } = {}) {
73
79
  const configPath = getProjectConfigPath(rootDir)
74
80
 
75
81
  try {
@@ -82,6 +88,13 @@ export async function loadProjectConfig(rootDir, servers = [], { logSuccess, log
82
88
  const { presets: migratedPresets, needsMigration: presetsNeedMigration } = migratePresets(presets, migratedApps)
83
89
 
84
90
  if (appsNeedMigration || presetsNeedMigration) {
91
+ if (!allowMigration) {
92
+ throw new ZephyrError(
93
+ 'Zephyr cannot run non-interactively because .zephyr/config.json needs migration. Rerun interactively once to update the config.',
94
+ {code: 'ZEPHYR_PROJECT_CONFIG_MIGRATION_REQUIRED'}
95
+ )
96
+ }
97
+
85
98
  await saveProjectConfig(rootDir, {
86
99
  apps: migratedApps,
87
100
  presets: migratedPresets
@@ -92,9 +105,27 @@ export async function loadProjectConfig(rootDir, servers = [], { logSuccess, log
92
105
  return { apps: migratedApps, presets: migratedPresets }
93
106
  } catch (error) {
94
107
  if (error.code === 'ENOENT') {
108
+ if (strict) {
109
+ throw new ZephyrError(
110
+ 'Zephyr cannot run non-interactively because .zephyr/config.json does not exist. Run an interactive deployment first to create it.',
111
+ {code: 'ZEPHYR_PROJECT_CONFIG_MISSING'}
112
+ )
113
+ }
114
+
95
115
  return { apps: [], presets: [] }
96
116
  }
97
117
 
118
+ if (error instanceof ZephyrError) {
119
+ throw error
120
+ }
121
+
122
+ if (strict) {
123
+ throw new ZephyrError(
124
+ 'Zephyr cannot run non-interactively because .zephyr/config.json could not be read.',
125
+ {code: 'ZEPHYR_PROJECT_CONFIG_INVALID', cause: error}
126
+ )
127
+ }
128
+
98
129
  logWarning?.('Failed to read .zephyr/config.json, starting with an empty list of apps.')
99
130
  return { apps: [], presets: [] }
100
131
  }
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
2
2
  import os from 'node:os'
3
3
  import path from 'node:path'
4
4
 
5
+ import {ZephyrError} from '../runtime/errors.mjs'
5
6
  import { ensureDirectory } from '../utils/paths.mjs'
6
7
  import { generateId } from '../utils/id.mjs'
7
8
 
@@ -25,7 +26,12 @@ export function migrateServers(servers) {
25
26
  return { servers: migrated, needsMigration }
26
27
  }
27
28
 
28
- export async function loadServers({ logSuccess, logWarning } = {}) {
29
+ export async function loadServers({
30
+ logSuccess,
31
+ logWarning,
32
+ strict = false,
33
+ allowMigration = true
34
+ } = {}) {
29
35
  try {
30
36
  const raw = await fs.readFile(SERVERS_FILE, 'utf8')
31
37
  const data = JSON.parse(raw)
@@ -34,6 +40,13 @@ export async function loadServers({ logSuccess, logWarning } = {}) {
34
40
  const { servers: migrated, needsMigration } = migrateServers(servers)
35
41
 
36
42
  if (needsMigration) {
43
+ if (!allowMigration) {
44
+ throw new ZephyrError(
45
+ 'Zephyr cannot run non-interactively because ~/.config/zephyr/servers.json needs migration. Rerun interactively once to update the config.',
46
+ {code: 'ZEPHYR_SERVERS_CONFIG_MIGRATION_REQUIRED'}
47
+ )
48
+ }
49
+
37
50
  await saveServers(migrated)
38
51
  logSuccess?.('Migrated servers configuration to use unique IDs.')
39
52
  }
@@ -41,9 +54,27 @@ export async function loadServers({ logSuccess, logWarning } = {}) {
41
54
  return migrated
42
55
  } catch (error) {
43
56
  if (error.code === 'ENOENT') {
57
+ if (strict) {
58
+ throw new ZephyrError(
59
+ 'Zephyr cannot run non-interactively because ~/.config/zephyr/servers.json does not exist. Run an interactive deployment first to create it.',
60
+ {code: 'ZEPHYR_SERVERS_CONFIG_MISSING'}
61
+ )
62
+ }
63
+
44
64
  return []
45
65
  }
46
66
 
67
+ if (error instanceof ZephyrError) {
68
+ throw error
69
+ }
70
+
71
+ if (strict) {
72
+ throw new ZephyrError(
73
+ 'Zephyr cannot run non-interactively because ~/.config/zephyr/servers.json could not be read.',
74
+ {code: 'ZEPHYR_SERVERS_CONFIG_INVALID', cause: error}
75
+ )
76
+ }
77
+
47
78
  logWarning?.('Failed to read servers.json, starting with an empty list.')
48
79
  return []
49
80
  }
@@ -54,4 +85,3 @@ export async function saveServers(servers) {
54
85
  const payload = JSON.stringify(servers, null, 2)
55
86
  await fs.writeFile(SERVERS_FILE, `${payload}\n`)
56
87
  }
57
-
@@ -2,6 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import process from 'node:process'
4
4
  import chalk from 'chalk'
5
+ import {ZephyrError} from './runtime/errors.mjs'
5
6
  import { runCommand as runCommandBase, runCommandCapture as runCommandCaptureBase } from './utils/command.mjs'
6
7
 
7
8
  function isLocalPathOutsideRepo(depPath, rootDir) {
@@ -262,7 +263,9 @@ async function commitDependencyUpdates(rootDir, updatedFiles, promptFn, logFn) {
262
263
  return true
263
264
  }
264
265
 
265
- async function validateLocalDependencies(rootDir, promptFn, logFn = null) {
266
+ async function validateLocalDependencies(rootDir, promptFn, logFn = null, {
267
+ interactive = true
268
+ } = {}) {
266
269
  const packageDeps = await scanPackageJsonDependencies(rootDir)
267
270
  const composerDeps = await scanComposerJsonDependencies(rootDir)
268
271
 
@@ -308,6 +311,13 @@ async function validateLocalDependencies(rootDir, promptFn, logFn = null) {
308
311
  const countText = allDeps.length === 1 ? 'dependency' : 'dependencies'
309
312
  const promptMessage = `Found ${countColored} local file ${countText} pointing outside the repository:\n\n${messages.join('\n\n')}\n\nUpdate to latest version?`
310
313
 
314
+ if (!interactive) {
315
+ throw new ZephyrError(
316
+ 'Zephyr cannot run non-interactively because local file dependencies point outside the repository and require confirmation to update.',
317
+ {code: 'ZEPHYR_DEPENDENCY_UPDATE_REQUIRED'}
318
+ )
319
+ }
320
+
311
321
  // Prompt user
312
322
  const { shouldUpdate } = await promptFn([
313
323
  {
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
2
2
  import os from 'node:os'
3
3
  import process from 'node:process'
4
4
 
5
+ import {ZephyrError} from '../runtime/errors.mjs'
5
6
  import { PROJECT_LOCK_FILE, ensureDirectory, getLockFilePath, getProjectConfigDir } from '../utils/paths.mjs'
6
7
 
7
8
  function createLockPayload() {
@@ -67,7 +68,26 @@ export async function readRemoteLock(ssh, remoteCwd) {
67
68
  return null
68
69
  }
69
70
 
70
- export async function compareLocksAndPrompt(rootDir, ssh, remoteCwd, { runPrompt, logWarning } = {}) {
71
+ function parseLockDetails(rawContent = '') {
72
+ try {
73
+ return JSON.parse(rawContent.trim())
74
+ } catch (_error) {
75
+ return { raw: rawContent.trim() }
76
+ }
77
+ }
78
+
79
+ function formatLockHolder(details = {}) {
80
+ const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
81
+ const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
82
+
83
+ return { startedBy, startedAt }
84
+ }
85
+
86
+ export async function compareLocksAndPrompt(rootDir, ssh, remoteCwd, {
87
+ runPrompt,
88
+ logWarning,
89
+ interactive = true
90
+ } = {}) {
71
91
  const localLock = await readLocalLock(rootDir)
72
92
  const remoteLock = await readRemoteLock(ssh, remoteCwd)
73
93
 
@@ -79,8 +99,15 @@ export async function compareLocksAndPrompt(rootDir, ssh, remoteCwd, { runPrompt
79
99
  const remoteKey = `${remoteLock.user}@${remoteLock.hostname}:${remoteLock.pid}:${remoteLock.startedAt}`
80
100
 
81
101
  if (localKey === remoteKey) {
82
- const startedBy = remoteLock.user ? `${remoteLock.user}@${remoteLock.hostname ?? 'unknown'}` : 'unknown user'
83
- const startedAt = remoteLock.startedAt ? ` at ${remoteLock.startedAt}` : ''
102
+ const { startedBy, startedAt } = formatLockHolder(remoteLock)
103
+
104
+ if (!interactive) {
105
+ throw new ZephyrError(
106
+ `Stale deployment lock detected on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/.zephyr/${PROJECT_LOCK_FILE} manually before rerunning with --non-interactive.`,
107
+ {code: 'ZEPHYR_STALE_REMOTE_LOCK'}
108
+ )
109
+ }
110
+
84
111
  const { shouldRemove } = await runPrompt([
85
112
  {
86
113
  type: 'confirm',
@@ -103,7 +130,11 @@ export async function compareLocksAndPrompt(rootDir, ssh, remoteCwd, { runPrompt
103
130
  return false
104
131
  }
105
132
 
106
- export async function acquireRemoteLock(ssh, remoteCwd, rootDir, { runPrompt, logWarning } = {}) {
133
+ export async function acquireRemoteLock(ssh, remoteCwd, rootDir, {
134
+ runPrompt,
135
+ logWarning,
136
+ interactive = true
137
+ } = {}) {
107
138
  const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
108
139
  const escapedLockPath = lockPath.replace(/'/g, "'\\''")
109
140
  const checkCommand = `mkdir -p .zephyr && if [ -f '${escapedLockPath}' ]; then cat '${escapedLockPath}'; else echo "LOCK_NOT_FOUND"; fi`
@@ -113,31 +144,17 @@ export async function acquireRemoteLock(ssh, remoteCwd, rootDir, { runPrompt, lo
113
144
  if (checkResult.stdout && checkResult.stdout.trim() !== 'LOCK_NOT_FOUND' && checkResult.stdout.trim() !== '') {
114
145
  const localLock = await readLocalLock(rootDir)
115
146
  if (localLock) {
116
- const removed = await compareLocksAndPrompt(rootDir, ssh, remoteCwd, { runPrompt, logWarning })
147
+ const removed = await compareLocksAndPrompt(rootDir, ssh, remoteCwd, { runPrompt, logWarning, interactive })
117
148
  if (!removed) {
118
- let details = {}
119
- try {
120
- details = JSON.parse(checkResult.stdout.trim())
121
- } catch (_error) {
122
- details = { raw: checkResult.stdout.trim() }
123
- }
124
-
125
- const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
126
- const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
149
+ const details = parseLockDetails(checkResult.stdout.trim())
150
+ const { startedBy, startedAt } = formatLockHolder(details)
127
151
  throw new Error(
128
152
  `Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/${lockPath} if you are sure it is stale.`
129
153
  )
130
154
  }
131
155
  } else {
132
- let details = {}
133
- try {
134
- details = JSON.parse(checkResult.stdout.trim())
135
- } catch (_error) {
136
- details = { raw: checkResult.stdout.trim() }
137
- }
138
-
139
- const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
140
- const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
156
+ const details = parseLockDetails(checkResult.stdout.trim())
157
+ const { startedBy, startedAt } = formatLockHolder(details)
141
158
  throw new Error(
142
159
  `Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/${lockPath} if you are sure it is stale.`
143
160
  )
@@ -168,4 +185,3 @@ export async function releaseRemoteLock(ssh, remoteCwd, { logWarning } = {}) {
168
185
  logWarning?.(`Failed to remove lock file: ${result.stderr}`)
169
186
  }
170
187
  }
171
-
package/src/main.mjs CHANGED
@@ -3,10 +3,12 @@ import {createRequire} from 'node:module'
3
3
  import path from 'node:path'
4
4
  import process from 'node:process'
5
5
 
6
+ import {validateCliOptions} from './cli/options.mjs'
6
7
  import {releaseNode} from './release-node.mjs'
7
8
  import {releasePackagist} from './release-packagist.mjs'
8
9
  import {validateLocalDependencies} from './dependency-scanner.mjs'
9
10
  import * as bootstrap from './project/bootstrap.mjs'
11
+ import {getErrorCode} from './runtime/errors.mjs'
10
12
  import {PROJECT_CONFIG_DIR} from './utils/paths.mjs'
11
13
  import {writeStderrLine} from './utils/output.mjs'
12
14
  import {createAppContext} from './runtime/app-context.mjs'
@@ -20,97 +22,223 @@ const RELEASE_SCRIPT_COMMAND = 'npx @wyxos/zephyr@latest'
20
22
  const require = createRequire(import.meta.url)
21
23
  const {version: ZEPHYR_VERSION} = require('../package.json')
22
24
 
23
- const appContext = createAppContext()
24
- const {
25
- logProcessing,
26
- logSuccess,
27
- logWarning,
28
- logError,
29
- runPrompt,
30
- runCommand
31
- } = appContext
32
- const configurationService = createConfigurationService(appContext)
25
+ function normalizeMainOptions(firstArg = null, secondArg = null) {
26
+ if (firstArg && typeof firstArg === 'object' && !Array.isArray(firstArg)) {
27
+ return {
28
+ workflowType: firstArg.workflowType ?? firstArg.type ?? null,
29
+ versionArg: firstArg.versionArg ?? null,
30
+ nonInteractive: firstArg.nonInteractive === true,
31
+ json: firstArg.json === true,
32
+ presetName: firstArg.presetName ?? null,
33
+ resumePending: firstArg.resumePending === true,
34
+ discardPending: firstArg.discardPending === true,
35
+ maintenanceMode: firstArg.maintenanceMode ?? null,
36
+ skipTests: firstArg.skipTests === true,
37
+ skipLint: firstArg.skipLint === true,
38
+ skipBuild: firstArg.skipBuild === true,
39
+ skipDeploy: firstArg.skipDeploy === true,
40
+ context: firstArg.context ?? null
41
+ }
42
+ }
43
+
44
+ return {
45
+ workflowType: firstArg ?? null,
46
+ versionArg: secondArg ?? null,
47
+ nonInteractive: false,
48
+ json: false,
49
+ presetName: null,
50
+ resumePending: false,
51
+ discardPending: false,
52
+ maintenanceMode: null,
53
+ skipTests: false,
54
+ skipLint: false,
55
+ skipBuild: false,
56
+ skipDeploy: false,
57
+ context: null
58
+ }
59
+ }
60
+
61
+ function resolveWorkflowName(workflowType = null) {
62
+ if (workflowType === 'node' || workflowType === 'vue') {
63
+ return `release-${workflowType}`
64
+ }
65
+
66
+ if (workflowType === 'packagist') {
67
+ return 'release-packagist'
68
+ }
69
+
70
+ return 'deploy'
71
+ }
33
72
 
34
73
  async function runRemoteTasks(config, options = {}) {
35
74
  return await runDeployment(config, {
36
75
  ...options,
37
- context: options.context ?? appContext
76
+ context: options.context
38
77
  })
39
78
  }
40
79
 
41
- async function main(releaseType = null, versionArg = null) {
42
- logProcessing(`Zephyr v${ZEPHYR_VERSION}`)
80
+ async function main(optionsOrWorkflowType = null, versionArg = null) {
81
+ const options = normalizeMainOptions(optionsOrWorkflowType, versionArg)
82
+
83
+ const executionMode = {
84
+ interactive: !options.nonInteractive,
85
+ json: options.json === true && options.nonInteractive === true,
86
+ workflow: resolveWorkflowName(options.workflowType),
87
+ presetName: options.presetName,
88
+ maintenanceMode: options.maintenanceMode,
89
+ resumePending: options.resumePending,
90
+ discardPending: options.discardPending
91
+ }
92
+ const appContext = options.context ?? createAppContext({executionMode})
93
+ const {
94
+ logProcessing,
95
+ logSuccess,
96
+ logWarning,
97
+ logError,
98
+ runPrompt,
99
+ runCommand,
100
+ emitEvent
101
+ } = appContext
102
+ const currentExecutionMode = appContext.executionMode ?? executionMode
103
+ const configurationService = createConfigurationService(appContext)
104
+
105
+ try {
106
+ validateCliOptions(options)
107
+
108
+ if (currentExecutionMode.json) {
109
+ emitEvent?.('run_started', {
110
+ message: `Zephyr v${ZEPHYR_VERSION} starting`,
111
+ data: {
112
+ version: ZEPHYR_VERSION,
113
+ workflow: currentExecutionMode.workflow,
114
+ nonInteractive: currentExecutionMode.interactive === false,
115
+ presetName: currentExecutionMode.presetName,
116
+ maintenanceMode: currentExecutionMode.maintenanceMode,
117
+ resumePending: currentExecutionMode.resumePending,
118
+ discardPending: currentExecutionMode.discardPending
119
+ }
120
+ })
121
+ } else {
122
+ logProcessing(`Zephyr v${ZEPHYR_VERSION}`)
123
+ }
43
124
 
44
- if (releaseType === 'node' || releaseType === 'vue') {
45
- try {
46
- await releaseNode()
125
+ if (options.workflowType === 'node' || options.workflowType === 'vue') {
126
+ await releaseNode({
127
+ releaseType: options.versionArg,
128
+ skipTests: options.skipTests,
129
+ skipLint: options.skipLint,
130
+ skipBuild: options.skipBuild,
131
+ skipDeploy: options.skipDeploy,
132
+ context: appContext
133
+ })
134
+ emitEvent?.('run_completed', {
135
+ message: 'Zephyr workflow completed successfully.',
136
+ data: {
137
+ version: ZEPHYR_VERSION,
138
+ workflow: currentExecutionMode.workflow
139
+ }
140
+ })
47
141
  return
48
- } catch (error) {
49
- logError('\nRelease failed:')
50
- logError(error.message)
51
- if (error.stack) {
52
- writeStderrLine(error.stack)
53
- }
54
- process.exit(1)
55
142
  }
56
- }
57
143
 
58
- if (releaseType === 'packagist') {
59
- try {
60
- await releasePackagist()
144
+ if (options.workflowType === 'packagist') {
145
+ await releasePackagist({
146
+ releaseType: options.versionArg,
147
+ skipTests: options.skipTests,
148
+ skipLint: options.skipLint,
149
+ context: appContext
150
+ })
151
+ emitEvent?.('run_completed', {
152
+ message: 'Zephyr workflow completed successfully.',
153
+ data: {
154
+ version: ZEPHYR_VERSION,
155
+ workflow: currentExecutionMode.workflow
156
+ }
157
+ })
61
158
  return
62
- } catch (error) {
63
- logError('\nRelease failed:')
64
- logError(error.message)
65
- if (error.stack) {
66
- writeStderrLine(error.stack)
67
- }
68
- process.exit(1)
69
159
  }
70
- }
71
160
 
72
- const rootDir = process.cwd()
161
+ const rootDir = process.cwd()
73
162
 
74
- await bootstrap.ensureGitignoreEntry(rootDir, {
75
- projectConfigDir: PROJECT_CONFIG_DIR,
76
- runCommand,
77
- logSuccess,
78
- logWarning
79
- })
80
- await bootstrap.ensureProjectReleaseScript(rootDir, {
81
- runPrompt,
82
- runCommand,
83
- logSuccess,
84
- logWarning,
85
- releaseScriptName: RELEASE_SCRIPT_NAME,
86
- releaseScriptCommand: RELEASE_SCRIPT_COMMAND
87
- })
163
+ await bootstrap.ensureGitignoreEntry(rootDir, {
164
+ projectConfigDir: PROJECT_CONFIG_DIR,
165
+ runCommand,
166
+ logSuccess,
167
+ logWarning
168
+ })
169
+ await bootstrap.ensureProjectReleaseScript(rootDir, {
170
+ runPrompt,
171
+ runCommand,
172
+ logSuccess,
173
+ logWarning,
174
+ interactive: currentExecutionMode.interactive,
175
+ releaseScriptName: RELEASE_SCRIPT_NAME,
176
+ releaseScriptCommand: RELEASE_SCRIPT_COMMAND
177
+ })
88
178
 
89
- const packageJsonPath = path.join(rootDir, 'package.json')
90
- const composerJsonPath = path.join(rootDir, 'composer.json')
91
- const hasPackageJson = await fs.access(packageJsonPath).then(() => true).catch(() => false)
92
- const hasComposerJson = await fs.access(composerJsonPath).then(() => true).catch(() => false)
179
+ const packageJsonPath = path.join(rootDir, 'package.json')
180
+ const composerJsonPath = path.join(rootDir, 'composer.json')
181
+ const hasPackageJson = await fs.access(packageJsonPath).then(() => true).catch(() => false)
182
+ const hasComposerJson = await fs.access(composerJsonPath).then(() => true).catch(() => false)
93
183
 
94
- if (hasPackageJson || hasComposerJson) {
95
- logProcessing('Validating dependencies...')
96
- await validateLocalDependencies(rootDir, runPrompt, logSuccess)
97
- }
184
+ if (hasPackageJson || hasComposerJson) {
185
+ logProcessing('Validating dependencies...')
186
+ await validateLocalDependencies(rootDir, runPrompt, logSuccess, {
187
+ interactive: currentExecutionMode.interactive
188
+ })
189
+ }
98
190
 
99
- const {deploymentConfig} = await selectDeploymentTarget(rootDir, {
100
- configurationService,
101
- runPrompt,
102
- logProcessing,
103
- logSuccess,
104
- logWarning
105
- })
191
+ const {deploymentConfig} = await selectDeploymentTarget(rootDir, {
192
+ configurationService,
193
+ runPrompt,
194
+ logProcessing,
195
+ logSuccess,
196
+ logWarning,
197
+ emitEvent,
198
+ executionMode: currentExecutionMode
199
+ })
106
200
 
107
- const snapshotToUse = await resolvePendingSnapshot(rootDir, deploymentConfig, {
108
- runPrompt,
109
- logProcessing,
110
- logWarning
111
- })
201
+ const snapshotToUse = await resolvePendingSnapshot(rootDir, deploymentConfig, {
202
+ runPrompt,
203
+ logProcessing,
204
+ logWarning,
205
+ executionMode: currentExecutionMode
206
+ })
207
+
208
+ await runRemoteTasks(deploymentConfig, {
209
+ rootDir,
210
+ snapshot: snapshotToUse,
211
+ versionArg: options.versionArg,
212
+ context: appContext
213
+ })
214
+
215
+ emitEvent?.('run_completed', {
216
+ message: 'Zephyr workflow completed successfully.',
217
+ data: {
218
+ version: ZEPHYR_VERSION,
219
+ workflow: currentExecutionMode.workflow
220
+ }
221
+ })
222
+ } catch (error) {
223
+ const errorCode = getErrorCode(error)
224
+ emitEvent?.('run_failed', {
225
+ message: error.message,
226
+ code: errorCode,
227
+ data: {
228
+ version: ZEPHYR_VERSION,
229
+ workflow: currentExecutionMode.workflow
230
+ }
231
+ })
112
232
 
113
- await runRemoteTasks(deploymentConfig, {rootDir, snapshot: snapshotToUse, versionArg})
233
+ if (!currentExecutionMode.json) {
234
+ logError(error.message)
235
+ if (errorCode === 'ZEPHYR_FAILURE' && error.stack) {
236
+ writeStderrLine(error.stack)
237
+ }
238
+ }
239
+
240
+ throw error
241
+ }
114
242
  }
115
243
 
116
244
  export {main, runRemoteTasks}
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
 
4
+ import {ZephyrError} from '../runtime/errors.mjs'
5
+
4
6
  export async function ensureGitignoreEntry(rootDir, {
5
7
  projectConfigDir = '.zephyr',
6
8
  runCommand,
@@ -66,6 +68,7 @@ export async function ensureProjectReleaseScript(rootDir, {
66
68
  runCommand,
67
69
  logSuccess,
68
70
  logWarning,
71
+ interactive = true,
69
72
  releaseScriptName = 'release',
70
73
  releaseScriptCommand = 'npx @wyxos/zephyr@latest'
71
74
  } = {}) {
@@ -96,6 +99,13 @@ export async function ensureProjectReleaseScript(rootDir, {
96
99
  return false
97
100
  }
98
101
 
102
+ if (!interactive) {
103
+ throw new ZephyrError(
104
+ 'Zephyr cannot run non-interactively because package.json is missing the Zephyr release script. Add `"release": "npx @wyxos/zephyr@latest"` and rerun.',
105
+ {code: 'ZEPHYR_RELEASE_SCRIPT_REQUIRED'}
106
+ )
107
+ }
108
+
99
109
  const { installReleaseScript } = await runPrompt([
100
110
  {
101
111
  type: 'confirm',
@@ -144,4 +154,3 @@ export async function ensureProjectReleaseScript(rootDir, {
144
154
 
145
155
  return true
146
156
  }
147
-
@@ -65,14 +65,23 @@ export function parseReleaseArgs({
65
65
 
66
66
  export async function runReleaseCommand(command, args, {
67
67
  cwd = process.cwd(),
68
- capture = false
68
+ capture = false,
69
+ runCommandImpl = runCommandBase,
70
+ runCommandCaptureImpl = runCommandCaptureBase
69
71
  } = {}) {
70
72
  if (capture) {
71
- const { stdout, stderr } = await runCommandCaptureBase(command, args, { cwd })
73
+ const captured = await runCommandCaptureImpl(command, args, { cwd })
74
+
75
+ if (typeof captured === 'string') {
76
+ return { stdout: captured.trim(), stderr: '' }
77
+ }
78
+
79
+ const stdout = captured?.stdout ?? ''
80
+ const stderr = captured?.stderr ?? ''
72
81
  return { stdout: stdout.trim(), stderr: stderr.trim() }
73
82
  }
74
83
 
75
- await runCommandBase(command, args, { cwd })
84
+ await runCommandImpl(command, args, { cwd })
76
85
  return undefined
77
86
  }
78
87
 
@@ -91,9 +100,10 @@ export async function ensureCleanWorkingTree(rootDir = process.cwd(), {
91
100
 
92
101
  export async function validateReleaseDependencies(rootDir = process.cwd(), {
93
102
  prompt = (questions) => inquirer.prompt(questions),
94
- logSuccess
103
+ logSuccess,
104
+ interactive = true
95
105
  } = {}) {
96
- await validateLocalDependencies(rootDir, prompt, logSuccess)
106
+ await validateLocalDependencies(rootDir, prompt, logSuccess, { interactive })
97
107
  }
98
108
 
99
109
  export async function ensureReleaseBranchReady({