@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.
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.1",
4
4
  "description": "A streamlined deployment tool for web applications with intelligent Laravel project detection",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -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
  }
@@ -325,10 +338,43 @@ async function resolveMaintenanceModePlan({
325
338
  })
326
339
  }
327
340
 
341
+ export async function resolveRemoteDeploymentState({
342
+ snapshot,
343
+ executionMode = {},
344
+ ssh,
345
+ remoteCwd,
346
+ runPrompt,
347
+ logSuccess,
348
+ logWarning
349
+ } = {}) {
350
+ const remoteIsLaravel = await detectRemoteLaravelProject(ssh, remoteCwd)
351
+
352
+ if (remoteIsLaravel) {
353
+ logSuccess?.('Laravel project detected.')
354
+ } else {
355
+ logWarning?.('Laravel project not detected; skipping Laravel-specific maintenance tasks.')
356
+ }
357
+
358
+ const maintenanceModeEnabled = await resolveMaintenanceMode({
359
+ snapshot,
360
+ remoteIsLaravel,
361
+ runPrompt,
362
+ executionMode
363
+ })
364
+
365
+ return {
366
+ remoteIsLaravel,
367
+ maintenanceModeEnabled
368
+ }
369
+ }
370
+
328
371
  export async function buildRemoteDeploymentPlan({
329
372
  config,
330
373
  snapshot = null,
331
374
  requiredPhpVersion = null,
375
+ executionMode = {},
376
+ remoteIsLaravel = null,
377
+ maintenanceModeEnabled = null,
332
378
  ssh,
333
379
  remoteCwd,
334
380
  executeRemote,
@@ -337,18 +383,26 @@ export async function buildRemoteDeploymentPlan({
337
383
  logWarning,
338
384
  runPrompt
339
385
  } = {}) {
340
- const remoteIsLaravel = await detectRemoteLaravelProject(ssh, remoteCwd)
341
-
342
- if (remoteIsLaravel) {
343
- logSuccess?.('Laravel project detected.')
344
- } else {
345
- logWarning?.('Laravel project not detected; skipping Laravel-specific maintenance tasks.')
346
- }
386
+ const remoteState = typeof remoteIsLaravel === 'boolean' &&
387
+ (remoteIsLaravel === false || typeof maintenanceModeEnabled === 'boolean')
388
+ ? {
389
+ remoteIsLaravel,
390
+ maintenanceModeEnabled: remoteIsLaravel ? maintenanceModeEnabled : false
391
+ }
392
+ : await resolveRemoteDeploymentState({
393
+ snapshot,
394
+ executionMode,
395
+ ssh,
396
+ remoteCwd,
397
+ runPrompt,
398
+ logSuccess,
399
+ logWarning
400
+ })
347
401
 
348
402
  const changedFiles = await collectChangedFiles({
349
403
  config,
350
404
  snapshot,
351
- remoteIsLaravel,
405
+ remoteIsLaravel: remoteState.remoteIsLaravel,
352
406
  executeRemote,
353
407
  logProcessing
354
408
  })
@@ -356,7 +410,7 @@ export async function buildRemoteDeploymentPlan({
356
410
  const horizonConfigured = await detectHorizonConfiguration({
357
411
  ssh,
358
412
  remoteCwd,
359
- remoteIsLaravel,
413
+ remoteIsLaravel: remoteState.remoteIsLaravel,
360
414
  changedFiles
361
415
  })
362
416
 
@@ -368,17 +422,11 @@ export async function buildRemoteDeploymentPlan({
368
422
  logWarning
369
423
  })
370
424
 
371
- const maintenanceModeEnabled = await resolveMaintenanceMode({
372
- snapshot,
373
- remoteIsLaravel,
374
- runPrompt
375
- })
376
-
377
425
  const maintenanceModePlan = await resolveMaintenanceModePlan({
378
426
  snapshot,
379
- remoteIsLaravel,
427
+ remoteIsLaravel: remoteState.remoteIsLaravel,
380
428
  remoteCwd,
381
- maintenanceModeEnabled,
429
+ maintenanceModeEnabled: remoteState.maintenanceModeEnabled,
382
430
  phpCommand,
383
431
  ssh,
384
432
  executeRemote,
@@ -388,7 +436,7 @@ export async function buildRemoteDeploymentPlan({
388
436
 
389
437
  const steps = planLaravelDeploymentTasks({
390
438
  branch: config.branch,
391
- isLaravel: remoteIsLaravel,
439
+ isLaravel: remoteState.remoteIsLaravel,
392
440
  changedFiles,
393
441
  horizonConfigured,
394
442
  phpCommand,
@@ -423,7 +471,7 @@ export async function buildRemoteDeploymentPlan({
423
471
  }
424
472
 
425
473
  return {
426
- remoteIsLaravel,
474
+ remoteIsLaravel: remoteState.remoteIsLaravel,
427
475
  changedFiles,
428
476
  horizonConfigured,
429
477
  phpCommand,
@@ -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',