@wyxos/zephyr 0.2.31 → 0.3.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.
Files changed (33) hide show
  1. package/README.md +55 -2
  2. package/bin/zephyr.mjs +3 -1
  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 +340 -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 +187 -67
  23. package/src/deploy/remote-exec.mjs +2 -3
  24. package/src/index.mjs +27 -85
  25. package/src/main.mjs +78 -641
  26. package/src/release/shared.mjs +104 -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
@@ -18,6 +18,12 @@ npx @wyxos/zephyr
18
18
 
19
19
  Navigate to your project directory and run:
20
20
 
21
+ ```bash
22
+ npm run release
23
+ ```
24
+
25
+ Or invoke Zephyr directly:
26
+
21
27
  ```bash
22
28
  zephyr
23
29
  ```
@@ -35,6 +41,11 @@ Common flags:
35
41
  zephyr --type node
36
42
  ```
37
43
 
44
+ On a first run inside a project with `package.json`, Zephyr can:
45
+ - add `.zephyr/` to `.gitignore`
46
+ - add a `release` script that runs `npx @wyxos/zephyr@latest`
47
+ - create global server config and per-project deployment config interactively
48
+
38
49
  Follow the interactive prompts to configure your deployment target:
39
50
  - Server name and IP address
40
51
  - Project path on the remote server
@@ -43,6 +54,48 @@ Follow the interactive prompts to configure your deployment target:
43
54
 
44
55
  Configuration is saved automatically for future deployments.
45
56
 
57
+ ## Scripts
58
+
59
+ Zephyr exposes the following common project workflow:
60
+
61
+ ```bash
62
+ npm run lint
63
+ npm test
64
+ npm run release
65
+ ```
66
+
67
+ - `npm run lint` runs `eslint . --fix`, so it can rewrite tracked files when auto-fixes are available.
68
+ - `npm test` runs the Vitest suite.
69
+ - `npm run release` is the recommended project entrypoint once the release script has been installed.
70
+
71
+ ## Package API
72
+
73
+ The package surface is intentionally small:
74
+
75
+ ```js
76
+ import {
77
+ logProcessing,
78
+ logSuccess,
79
+ logWarning,
80
+ logError,
81
+ runCommand,
82
+ runCommandCapture,
83
+ writeToLogFile
84
+ } from '@wyxos/zephyr'
85
+
86
+ import {selectDeploymentTarget} from '@wyxos/zephyr/targets'
87
+
88
+ import {
89
+ connectToServer,
90
+ executeRemoteCommand,
91
+ readRemoteFile,
92
+ downloadRemoteFile,
93
+ deleteRemoteFile
94
+ } from '@wyxos/zephyr/ssh'
95
+ ```
96
+
97
+ The root package does not expose the old interactive configuration helper bag; target selection now lives under `@wyxos/zephyr/targets`.
98
+
46
99
  ## Features
47
100
 
48
101
  - Automated Git operations (branch switching, commits, pushes)
@@ -62,7 +115,7 @@ Configuration is saved automatically for future deployments.
62
115
  Zephyr analyzes changed files and runs appropriate tasks:
63
116
 
64
117
  - **Always**: `git pull origin <branch>`
65
- - **Composer files changed** (`composer.json` / `composer.lock`): `composer update --no-dev --no-interaction --prefer-dist`
118
+ - **Composer files changed** (`composer.json` / `composer.lock`): `composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader` (requires `composer.lock`)
66
119
  - **Migrations changed** (`database/migrations/*.php`): `php artisan migrate --force`
67
120
  - **Node dependency files changed** (`package.json` / `package-lock.json`, including nested): `npm install`
68
121
  - **Frontend files changed** (`.vue/.js/.ts/.tsx/.css/.scss/.less`): `npm run build`
@@ -131,4 +184,4 @@ The `.zephyr/` directory is automatically added to `.gitignore`.
131
184
 
132
185
  - Node.js 16+
133
186
  - Git
134
- - SSH access to target servers
187
+ - SSH access to target servers
package/bin/zephyr.mjs CHANGED
@@ -13,12 +13,14 @@ program
13
13
  .name('zephyr')
14
14
  .description('A streamlined deployment tool for web applications with intelligent Laravel project detection')
15
15
  .option('--type <type>', 'Release type (node|vue|packagist)')
16
+ .argument('[version]', 'Version or npm bump type (e.g. 1.2.3, patch, minor, major)')
16
17
 
17
18
  program.parse(process.argv)
18
19
  const options = program.opts()
20
+ const versionArg = program.args[0] ?? null
19
21
 
20
22
  try {
21
- await main(options.type ?? null)
23
+ await main(options.type ?? null, versionArg)
22
24
  } catch (error) {
23
25
  logError(error?.message || String(error))
24
26
  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.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",
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
+ }