@wyxos/zephyr 0.2.31 → 0.3.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.
Files changed (33) hide show
  1. package/README.md +42 -5
  2. package/bin/zephyr.mjs +7 -2
  3. package/package.json +7 -2
  4. package/src/application/configuration/app-details.mjs +89 -0
  5. package/src/application/configuration/app-selection.mjs +87 -0
  6. package/src/application/configuration/preset-selection.mjs +59 -0
  7. package/src/application/configuration/select-deployment-target.mjs +165 -0
  8. package/src/application/configuration/server-selection.mjs +87 -0
  9. package/src/application/configuration/service.mjs +109 -0
  10. package/src/application/deploy/build-remote-deployment-plan.mjs +174 -0
  11. package/src/application/deploy/bump-local-package-version.mjs +81 -0
  12. package/src/application/deploy/execute-remote-deployment-plan.mjs +61 -0
  13. package/src/{utils/task-planner.mjs → application/deploy/plan-laravel-deployment-tasks.mjs} +5 -4
  14. package/src/application/deploy/prepare-local-deployment.mjs +52 -0
  15. package/src/application/deploy/resolve-local-deployment-context.mjs +17 -0
  16. package/src/application/deploy/resolve-pending-snapshot.mjs +45 -0
  17. package/src/application/deploy/run-deployment.mjs +147 -0
  18. package/src/application/deploy/run-local-deployment-checks.mjs +80 -0
  19. package/src/application/release/release-node-package.mjs +341 -0
  20. package/src/application/release/release-packagist-package.mjs +223 -0
  21. package/src/config/project.mjs +13 -0
  22. package/src/deploy/local-repo.mjs +188 -68
  23. package/src/deploy/remote-exec.mjs +2 -3
  24. package/src/index.mjs +15 -84
  25. package/src/main.mjs +78 -641
  26. package/src/release/shared.mjs +120 -0
  27. package/src/release-node.mjs +20 -424
  28. package/src/release-packagist.mjs +20 -291
  29. package/src/runtime/app-context.mjs +36 -0
  30. package/src/targets/index.mjs +24 -0
  31. package/src/utils/output.mjs +41 -16
  32. package/src/utils/config-flow.mjs +0 -284
  33. /package/src/{utils/php-version.mjs → infrastructure/php/version.mjs} +0 -0
package/README.md CHANGED
@@ -16,7 +16,13 @@ npx @wyxos/zephyr
16
16
 
17
17
  ## Usage
18
18
 
19
- Navigate to your project directory and run:
19
+ Navigate to your app or package directory and run:
20
+
21
+ ```bash
22
+ npm run release
23
+ ```
24
+
25
+ Or invoke Zephyr directly:
20
26
 
21
27
  ```bash
22
28
  zephyr
@@ -28,13 +34,32 @@ See all flags:
28
34
  zephyr --help
29
35
  ```
30
36
 
31
- Common flags:
37
+ Common workflows:
32
38
 
33
39
  ```bash
34
- # Run a release workflow
40
+ # Deploy an app using the saved preset or the interactive prompts
41
+ zephyr
42
+
43
+ # Deploy an app and bump the local npm package version first
44
+ zephyr minor
45
+
46
+ # Release a Node/Vue package (defaults to a patch bump)
35
47
  zephyr --type node
48
+
49
+ # Release a Node/Vue package with an explicit bump
50
+ zephyr --type node minor
51
+
52
+ # Release a Packagist package
53
+ zephyr --type packagist patch
36
54
  ```
37
55
 
56
+ When `--type node` or `--type vue` is used without a bump argument, Zephyr defaults to `patch`.
57
+
58
+ On a first run inside a project with `package.json`, Zephyr can:
59
+ - add `.zephyr/` to `.gitignore`
60
+ - add a `release` script that runs `npx @wyxos/zephyr@latest`
61
+ - create global server config and per-project deployment config interactively
62
+
38
63
  Follow the interactive prompts to configure your deployment target:
39
64
  - Server name and IP address
40
65
  - Project path on the remote server
@@ -43,6 +68,18 @@ Follow the interactive prompts to configure your deployment target:
43
68
 
44
69
  Configuration is saved automatically for future deployments.
45
70
 
71
+ ## Project Scripts
72
+
73
+ The recommended entrypoint in consumer projects is:
74
+
75
+ ```bash
76
+ npm run release
77
+ ```
78
+
79
+ - `npm run release` is the recommended app/package entrypoint once the release script has been installed.
80
+ - For `--type node` workflows, Zephyr runs your project's `lint` script when present.
81
+ - For `--type node` workflows, Zephyr runs `test:run` or `test` when present.
82
+
46
83
  ## Features
47
84
 
48
85
  - Automated Git operations (branch switching, commits, pushes)
@@ -62,7 +99,7 @@ Configuration is saved automatically for future deployments.
62
99
  Zephyr analyzes changed files and runs appropriate tasks:
63
100
 
64
101
  - **Always**: `git pull origin <branch>`
65
- - **Composer files changed** (`composer.json` / `composer.lock`): `composer update --no-dev --no-interaction --prefer-dist`
102
+ - **Composer files changed** (`composer.json` / `composer.lock`): `composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader` (requires `composer.lock`)
66
103
  - **Migrations changed** (`database/migrations/*.php`): `php artisan migrate --force`
67
104
  - **Node dependency files changed** (`package.json` / `package-lock.json`, including nested): `npm install`
68
105
  - **Frontend files changed** (`.vue/.js/.ts/.tsx/.css/.scss/.less`): `npm run build`
@@ -131,4 +168,4 @@ The `.zephyr/` directory is automatically added to `.gitignore`.
131
168
 
132
169
  - Node.js 16+
133
170
  - Git
134
- - SSH access to target servers
171
+ - SSH access to target servers
package/bin/zephyr.mjs CHANGED
@@ -12,13 +12,18 @@ const program = new Command()
12
12
  program
13
13
  .name('zephyr')
14
14
  .description('A streamlined deployment tool for web applications with intelligent Laravel project detection')
15
- .option('--type <type>', 'Release type (node|vue|packagist)')
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
+ )
16
20
 
17
21
  program.parse(process.argv)
18
22
  const options = program.opts()
23
+ const versionArg = program.args[0] ?? null
19
24
 
20
25
  try {
21
- await main(options.type ?? null)
26
+ await main(options.type ?? null, versionArg)
22
27
  } catch (error) {
23
28
  logError(error?.message || String(error))
24
29
  process.exitCode = 1
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.2.31",
3
+ "version": "0.3.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",
7
+ "imports": {
8
+ "#src/*": "./src/*",
9
+ "#tests/*": "./tests/*"
10
+ },
7
11
  "exports": {
8
12
  ".": "./src/index.mjs",
13
+ "./targets": "./src/targets/index.mjs",
9
14
  "./ssh": "./src/ssh/index.mjs"
10
15
  },
11
16
  "bin": {
@@ -13,7 +18,7 @@
13
18
  },
14
19
  "scripts": {
15
20
  "test": "vitest run",
16
- "lint": "eslint .",
21
+ "lint": "eslint . --fix",
17
22
  "release": "node bin/zephyr.mjs --type=node"
18
23
  },
19
24
  "keywords": [
@@ -0,0 +1,89 @@
1
+ import path from 'node:path'
2
+ import inquirer from 'inquirer'
3
+
4
+ export function defaultProjectPath(currentDir) {
5
+ return `~/webapps/${path.basename(currentDir)}`
6
+ }
7
+
8
+ export async function listGitBranches({
9
+ currentDir,
10
+ runCommandCapture,
11
+ logWarning
12
+ } = {}) {
13
+ try {
14
+ const output = await runCommandCapture(
15
+ 'git',
16
+ ['branch', '--format', '%(refname:short)'],
17
+ {cwd: currentDir}
18
+ )
19
+
20
+ const branches = output
21
+ .split(/\r?\n/)
22
+ .map((line) => line.trim())
23
+ .filter(Boolean)
24
+
25
+ return branches.length ? branches : ['master']
26
+ } catch (_error) {
27
+ logWarning?.('Unable to read git branches; defaulting to master.')
28
+ return ['master']
29
+ }
30
+ }
31
+
32
+ export async function promptAppDetails({
33
+ currentDir,
34
+ existing = {},
35
+ runPrompt,
36
+ listGitBranches,
37
+ resolveDefaultProjectPath = defaultProjectPath,
38
+ promptSshDetails
39
+ } = {}) {
40
+ const branches = await listGitBranches(currentDir)
41
+ const defaultBranch = existing.branch || (branches.includes('master') ? 'master' : branches[0])
42
+ const defaults = {
43
+ projectPath: existing.projectPath || resolveDefaultProjectPath(currentDir),
44
+ branch: defaultBranch
45
+ }
46
+
47
+ const answers = await runPrompt([
48
+ {
49
+ type: 'input',
50
+ name: 'projectPath',
51
+ message: 'Remote project path',
52
+ default: defaults.projectPath
53
+ },
54
+ {
55
+ type: 'list',
56
+ name: 'branchSelection',
57
+ message: 'Branch to deploy',
58
+ choices: [
59
+ ...branches.map((branch) => ({name: branch, value: branch})),
60
+ new inquirer.Separator(),
61
+ {name: 'Enter custom branch…', value: '__custom'}
62
+ ],
63
+ default: defaults.branch
64
+ }
65
+ ])
66
+
67
+ let branch = answers.branchSelection
68
+
69
+ if (branch === '__custom') {
70
+ const {customBranch} = await runPrompt([
71
+ {
72
+ type: 'input',
73
+ name: 'customBranch',
74
+ message: 'Custom branch name',
75
+ default: defaults.branch
76
+ }
77
+ ])
78
+
79
+ branch = customBranch.trim() || defaults.branch
80
+ }
81
+
82
+ const sshDetails = await promptSshDetails(currentDir, existing)
83
+
84
+ return {
85
+ projectPath: answers.projectPath.trim() || defaults.projectPath,
86
+ branch,
87
+ ...sshDetails
88
+ }
89
+ }
@@ -0,0 +1,87 @@
1
+ import inquirer from 'inquirer'
2
+
3
+ import {generateId} from '../../utils/id.mjs'
4
+ import {saveProjectConfig} from '../../config/project.mjs'
5
+
6
+ export async function selectApp({
7
+ projectConfig,
8
+ server,
9
+ currentDir,
10
+ runPrompt,
11
+ logWarning,
12
+ logProcessing,
13
+ logSuccess,
14
+ persistProjectConfig = saveProjectConfig,
15
+ createId = generateId,
16
+ promptAppDetails
17
+ } = {}) {
18
+ const apps = projectConfig.apps ?? []
19
+ const matches = apps
20
+ .map((app, index) => ({app, index}))
21
+ .filter(({app}) => app.serverId === server.id || app.serverName === server.serverName)
22
+
23
+ if (matches.length === 0) {
24
+ if (apps.length > 0) {
25
+ const availableServers = [...new Set(apps.map((app) => app.serverName).filter(Boolean))]
26
+ if (availableServers.length > 0) {
27
+ logWarning?.(
28
+ `No applications configured for server "${server.serverName}". Available servers: ${availableServers.join(', ')}`
29
+ )
30
+ }
31
+ }
32
+
33
+ logProcessing?.(`No applications are configured for ${server.serverName}. Creating one now.`)
34
+ const appDetails = await promptAppDetails(currentDir)
35
+ const appConfig = {
36
+ id: createId(),
37
+ serverId: server.id,
38
+ serverName: server.serverName,
39
+ ...appDetails
40
+ }
41
+
42
+ projectConfig.apps.push(appConfig)
43
+ await persistProjectConfig(currentDir, projectConfig)
44
+ logSuccess?.('Saved deployment configuration to .zephyr/config.json')
45
+ return appConfig
46
+ }
47
+
48
+ const choices = matches.map(({app}, matchIndex) => ({
49
+ name: `${app.projectPath} (${app.branch})`,
50
+ value: matchIndex
51
+ }))
52
+
53
+ choices.push(
54
+ new inquirer.Separator(),
55
+ {
56
+ name: '➕ Create a new application for this server',
57
+ value: 'create'
58
+ }
59
+ )
60
+
61
+ const {selection} = await runPrompt([
62
+ {
63
+ type: 'list',
64
+ name: 'selection',
65
+ message: `Select an application for ${server.serverName}`,
66
+ choices,
67
+ default: 0
68
+ }
69
+ ])
70
+
71
+ if (selection === 'create') {
72
+ const appDetails = await promptAppDetails(currentDir)
73
+ const appConfig = {
74
+ id: createId(),
75
+ serverId: server.id,
76
+ serverName: server.serverName,
77
+ ...appDetails
78
+ }
79
+
80
+ projectConfig.apps.push(appConfig)
81
+ await persistProjectConfig(currentDir, projectConfig)
82
+ logSuccess?.('Appended deployment configuration to .zephyr/config.json')
83
+ return appConfig
84
+ }
85
+
86
+ return matches[selection].app
87
+ }
@@ -0,0 +1,59 @@
1
+ import inquirer from 'inquirer'
2
+
3
+ export async function selectPreset({
4
+ projectConfig,
5
+ servers,
6
+ runPrompt
7
+ } = {}) {
8
+ const presets = projectConfig.presets ?? []
9
+ const apps = projectConfig.apps ?? []
10
+
11
+ if (presets.length === 0) {
12
+ return null
13
+ }
14
+
15
+ const choices = presets.map((preset, index) => {
16
+ let displayName = preset.name
17
+
18
+ if (preset.appId) {
19
+ const app = apps.find((entry) => entry.id === preset.appId)
20
+ if (app) {
21
+ const server = servers.find((entry) => entry.id === app.serverId || entry.serverName === app.serverName)
22
+ const serverName = server?.serverName || 'unknown'
23
+ const branch = preset.branch || app.branch || 'unknown'
24
+ displayName = `${preset.name} (${serverName} → ${app.projectPath} [${branch}])`
25
+ }
26
+ } else if (preset.key) {
27
+ const keyParts = preset.key.split(':')
28
+ const serverName = keyParts[0]
29
+ const projectPath = keyParts[1]
30
+ const branch = preset.branch || (keyParts.length === 3 ? keyParts[2] : 'unknown')
31
+ displayName = `${preset.name} (${serverName} → ${projectPath} [${branch}])`
32
+ }
33
+
34
+ return {
35
+ name: displayName,
36
+ value: index
37
+ }
38
+ })
39
+
40
+ choices.push(
41
+ new inquirer.Separator(),
42
+ {
43
+ name: '➕ Create a new preset',
44
+ value: 'create'
45
+ }
46
+ )
47
+
48
+ const {selection} = await runPrompt([
49
+ {
50
+ type: 'list',
51
+ name: 'selection',
52
+ message: 'Select a preset',
53
+ choices,
54
+ default: 0
55
+ }
56
+ ])
57
+
58
+ return selection === 'create' ? 'create' : presets[selection]
59
+ }
@@ -0,0 +1,165 @@
1
+ import {writeStdoutLine} from '../../utils/output.mjs'
2
+ import {loadServers} from '../../config/servers.mjs'
3
+ import {loadProjectConfig, removePreset, saveProjectConfig} from '../../config/project.mjs'
4
+
5
+ export async function selectDeploymentTarget(rootDir, {
6
+ configurationService,
7
+ runPrompt,
8
+ logProcessing,
9
+ logSuccess,
10
+ logWarning
11
+ } = {}) {
12
+ const servers = await loadServers({logSuccess, logWarning})
13
+ const projectConfig = await loadProjectConfig(rootDir, servers, {logSuccess, logWarning})
14
+
15
+ let server = null
16
+ let appConfig = null
17
+ let isCreatingNewPreset = false
18
+
19
+ const preset = await configurationService.selectPreset(projectConfig, servers)
20
+
21
+ const removeInvalidPreset = async () => {
22
+ if (!preset || preset === 'create') {
23
+ return
24
+ }
25
+
26
+ const removedPreset = removePreset(projectConfig, preset)
27
+ if (!removedPreset) {
28
+ return
29
+ }
30
+
31
+ await saveProjectConfig(rootDir, projectConfig)
32
+ const presetLabel = removedPreset.name ? `"${removedPreset.name}"` : 'selected preset'
33
+ logWarning?.(`Removed ${presetLabel} from .zephyr/config.json because it is invalid.`)
34
+ isCreatingNewPreset = true
35
+ }
36
+
37
+ if (preset === 'create') {
38
+ isCreatingNewPreset = true
39
+ server = await configurationService.selectServer(servers)
40
+ appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
41
+ } else if (preset) {
42
+ if (preset.appId) {
43
+ appConfig = projectConfig.apps?.find((app) => app.id === preset.appId)
44
+
45
+ if (!appConfig) {
46
+ logWarning?.('Preset references an application that no longer exists. Creating a new configuration instead.')
47
+ await removeInvalidPreset()
48
+ server = await configurationService.selectServer(servers)
49
+ appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
50
+ } else {
51
+ server = servers.find((entry) => entry.id === appConfig.serverId || entry.serverName === appConfig.serverName)
52
+
53
+ if (!server) {
54
+ logWarning?.('Preset references a server that no longer exists. Creating a new configuration instead.')
55
+ await removeInvalidPreset()
56
+ server = await configurationService.selectServer(servers)
57
+ appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
58
+ } else if (preset.branch && appConfig.branch !== preset.branch) {
59
+ appConfig.branch = preset.branch
60
+ await saveProjectConfig(rootDir, projectConfig)
61
+ logSuccess?.(`Updated branch to ${preset.branch} from preset.`)
62
+ }
63
+ }
64
+ } else if (preset.key) {
65
+ const keyParts = preset.key.split(':')
66
+ const serverName = keyParts[0]
67
+ const projectPath = keyParts[1]
68
+ const presetBranch = preset.branch || (keyParts.length === 3 ? keyParts[2] : null)
69
+
70
+ server = servers.find((entry) => entry.serverName === serverName)
71
+
72
+ if (!server) {
73
+ logWarning?.(`Preset references server "${serverName}" which no longer exists. Creating a new configuration instead.`)
74
+ await removeInvalidPreset()
75
+ server = await configurationService.selectServer(servers)
76
+ appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
77
+ } else {
78
+ appConfig = projectConfig.apps?.find(
79
+ (app) => (app.serverId === server.id || app.serverName === serverName) && app.projectPath === projectPath
80
+ )
81
+
82
+ if (!appConfig) {
83
+ logWarning?.('Preset references an application that no longer exists. Creating a new configuration instead.')
84
+ await removeInvalidPreset()
85
+ appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
86
+ } else {
87
+ preset.appId = appConfig.id
88
+ if (presetBranch && appConfig.branch !== presetBranch) {
89
+ appConfig.branch = presetBranch
90
+ }
91
+ preset.branch = appConfig.branch
92
+ await saveProjectConfig(rootDir, projectConfig)
93
+ }
94
+ }
95
+ } else {
96
+ logWarning?.('Preset format is invalid. Creating a new configuration instead.')
97
+ await removeInvalidPreset()
98
+ server = await configurationService.selectServer(servers)
99
+ appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
100
+ }
101
+ } else {
102
+ server = await configurationService.selectServer(servers)
103
+ appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
104
+ }
105
+
106
+ const updated = await configurationService.ensureSshDetails(appConfig, rootDir)
107
+
108
+ if (updated) {
109
+ await saveProjectConfig(rootDir, projectConfig)
110
+ logSuccess?.('Updated .zephyr/config.json with SSH details.')
111
+ }
112
+
113
+ const deploymentConfig = {
114
+ serverName: server.serverName,
115
+ serverIp: server.serverIp,
116
+ projectPath: appConfig.projectPath,
117
+ branch: appConfig.branch,
118
+ sshUser: appConfig.sshUser,
119
+ sshKey: appConfig.sshKey
120
+ }
121
+
122
+ logProcessing?.('\nSelected deployment target:')
123
+ writeStdoutLine(JSON.stringify(deploymentConfig, null, 2))
124
+
125
+ if (isCreatingNewPreset || !preset) {
126
+ const {presetName} = await runPrompt([
127
+ {
128
+ type: 'input',
129
+ name: 'presetName',
130
+ message: 'Enter a name for this preset (leave blank to skip)',
131
+ default: isCreatingNewPreset ? '' : undefined
132
+ }
133
+ ])
134
+
135
+ const trimmedName = presetName?.trim()
136
+
137
+ if (trimmedName && trimmedName.length > 0) {
138
+ const presets = projectConfig.presets ?? []
139
+ const appId = appConfig.id
140
+
141
+ if (!appId) {
142
+ logWarning?.('Cannot save preset: app configuration missing ID.')
143
+ } else {
144
+ const existingIndex = presets.findIndex((entry) => entry.appId === appId)
145
+
146
+ if (existingIndex >= 0) {
147
+ presets[existingIndex].name = trimmedName
148
+ presets[existingIndex].branch = deploymentConfig.branch
149
+ } else {
150
+ presets.push({
151
+ name: trimmedName,
152
+ appId,
153
+ branch: deploymentConfig.branch
154
+ })
155
+ }
156
+
157
+ projectConfig.presets = presets
158
+ await saveProjectConfig(rootDir, projectConfig)
159
+ logSuccess?.(`Saved preset "${trimmedName}" to .zephyr/config.json`)
160
+ }
161
+ }
162
+ }
163
+
164
+ return {deploymentConfig, projectConfig}
165
+ }
@@ -0,0 +1,87 @@
1
+ import inquirer from 'inquirer'
2
+
3
+ import {generateId} from '../../utils/id.mjs'
4
+ import {saveServers} from '../../config/servers.mjs'
5
+
6
+ export async function promptServerDetails({
7
+ existingServers = [],
8
+ runPrompt,
9
+ createId = generateId
10
+ } = {}) {
11
+ const defaults = {
12
+ serverName: existingServers.length === 0 ? 'home' : `server-${existingServers.length + 1}`,
13
+ serverIp: '1.1.1.1'
14
+ }
15
+
16
+ const answers = await runPrompt([
17
+ {
18
+ type: 'input',
19
+ name: 'serverName',
20
+ message: 'Enter a server name',
21
+ default: defaults.serverName
22
+ },
23
+ {
24
+ type: 'input',
25
+ name: 'serverIp',
26
+ message: 'Enter the server IP address',
27
+ default: defaults.serverIp
28
+ }
29
+ ])
30
+
31
+ return {
32
+ id: createId(),
33
+ serverName: answers.serverName.trim() || defaults.serverName,
34
+ serverIp: answers.serverIp.trim() || defaults.serverIp
35
+ }
36
+ }
37
+
38
+ export async function selectServer({
39
+ servers,
40
+ runPrompt,
41
+ logProcessing,
42
+ logSuccess,
43
+ persistServers = saveServers,
44
+ promptServerDetails
45
+ } = {}) {
46
+ if (servers.length === 0) {
47
+ logProcessing?.('No servers are configured yet. Creating one now.')
48
+ const server = await promptServerDetails()
49
+ servers.push(server)
50
+ await persistServers(servers)
51
+ logSuccess?.('Saved server configuration to ~/.config/zephyr/servers.json')
52
+ return server
53
+ }
54
+
55
+ const choices = servers.map((server, index) => ({
56
+ name: `${server.serverName} (${server.serverIp})`,
57
+ value: index
58
+ }))
59
+
60
+ choices.push(
61
+ new inquirer.Separator(),
62
+ {
63
+ name: '➕ Create a new server',
64
+ value: 'create'
65
+ }
66
+ )
67
+
68
+ const {selection} = await runPrompt([
69
+ {
70
+ type: 'list',
71
+ name: 'selection',
72
+ message: 'Select a server',
73
+ choices,
74
+ default: 0
75
+ }
76
+ ])
77
+
78
+ if (selection === 'create') {
79
+ const server = await promptServerDetails(servers)
80
+ servers.push(server)
81
+ await persistServers(servers)
82
+ logSuccess?.('Appended server configuration to ~/.config/zephyr/servers.json')
83
+ return server
84
+ }
85
+
86
+ return servers[selection]
87
+ }