@wyxos/zephyr 0.1.2 → 0.1.4

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.
@@ -0,0 +1,35 @@
1
+ # Copilot Instructions
2
+
3
+ ## Project Snapshot
4
+ - Command-line deployment tool (`bin/zephyr.mjs`) that delegates to `src/index.mjs` for all logic.
5
+ - Node.js ESM project; keep imports as `import … from` and avoid CommonJS helpers.
6
+ - Primary responsibilities: gather deployment config via prompts, ensure local git state, SSH into remote servers, run per-change maintenance tasks.
7
+
8
+ ## Configuration Model
9
+ - Global servers live at `~/.config/zephyr/servers.json` (array of `{ serverName, serverIp }`).
10
+ - Per-project apps live at `.zephyr/config.json` (apps array with `{ serverName, projectPath, branch, sshUser, sshKey }`).
11
+ - `main()` now sequences: ensure `.zephyr/` ignored, load servers, pick/create one, load project config, pick/create app, ensure SSH details, run deployment.
12
+ - When adding config logic, reuse helpers: `selectServer`, `promptServerDetails`, `selectApp`, `promptAppDetails`, `ensureProjectConfig`.
13
+
14
+ ## Deployment Flow Highlights
15
+ - Always call `ensureLocalRepositoryState(branch)` before SSH. It:
16
+ - Verifies current branch, fast-forwards with `git pull --ff-only`, warns if ahead, commits + pushes uncommitted changes when needed.
17
+ - Prompts for commit message if dirty and pushes to `origin/<branch>`.
18
+ - Remote execution happens via `runRemoteTasks(config)`; keep all SSH commands funneled through `executeRemote(label, command, options)` to inherit logging and error handling.
19
+ - Laravel detection toggles extra tasks—Composer, migrations, npm install/build, cache clears, queue restarts—based on changed files from `git diff HEAD..origin/<branch>`.
20
+
21
+ ## Release Workflow
22
+ - Automated publishing script at `publish.mjs` (`npm run release`):
23
+ - Checks clean working tree, fetches & fast-forwards branch, runs `npx vitest run`, bumps version via `npm version <type>`, pushes with tags, publishes (adds `--access public` for scoped packages).
24
+ - `npm pkg fix` may adjust `package.json`; commit results before running the release.
25
+
26
+ ## Testing & Tooling
27
+ - Test suite: `npm test` (Vitest). Mocks for fs, child_process, inquirer, node-ssh are set up—extend them for new behaviors rather than shelling out.
28
+ - Avoid long-running watchers in scripts; tests spawn Vitest in watch mode by default, so kill (`pkill -f vitest`) after scripted runs when necessary.
29
+
30
+ ## Conventions & Style
31
+ - Logging helpers (`logProcessing`, `logSuccess`, `logWarning`, `logError`) centralize colored output—use them instead of `console.log` in new deployment logic.
32
+ - Use async/await with `runCommand` / `runCommandCapture` for local shell ops; never `exec` directly.
33
+ - Keep new prompts routed through `runPrompt`; it supports injection for tests.
34
+ - Default to ASCII in files; comments only where logic is non-obvious.
35
+ - Update Vitest cases in `tests/index.test.js` when altering prompts, config structure, or deployment steps; tests expect deterministic logging text.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "vitest",
package/publish.mjs CHANGED
@@ -97,6 +97,18 @@ async function ensureUpToDateWithUpstream(branch, upstreamRef) {
97
97
  return
98
98
  }
99
99
 
100
+ const [remoteName, ...branchParts] = upstreamRef.split('/')
101
+ const remoteBranch = branchParts.join('/')
102
+
103
+ if (remoteName && remoteBranch) {
104
+ logStep(`Fetching latest updates from ${remoteName}/${remoteBranch}...`)
105
+ try {
106
+ await runCommand('git', ['fetch', remoteName, remoteBranch])
107
+ } catch (error) {
108
+ throw new Error(`Failed to fetch ${upstreamRef}: ${error.message}`)
109
+ }
110
+ }
111
+
100
112
  const aheadResult = await runCommand('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
101
113
  capture: true
102
114
  })
@@ -108,6 +120,20 @@ async function ensureUpToDateWithUpstream(branch, upstreamRef) {
108
120
  const behind = Number.parseInt(behindResult.stdout || '0', 10)
109
121
 
110
122
  if (Number.isFinite(behind) && behind > 0) {
123
+ if (remoteName && remoteBranch) {
124
+ logStep(`Fast-forwarding ${branch} with ${upstreamRef}...`)
125
+
126
+ try {
127
+ await runCommand('git', ['pull', '--ff-only', remoteName, remoteBranch])
128
+ } catch (error) {
129
+ throw new Error(
130
+ `Unable to fast-forward ${branch} with ${upstreamRef}. Resolve conflicts manually, then rerun the release.\n${error.message}`
131
+ )
132
+ }
133
+
134
+ return ensureUpToDateWithUpstream(branch, upstreamRef)
135
+ }
136
+
111
137
  throw new Error(
112
138
  `Branch ${branch} is behind ${upstreamRef} by ${behind} commit${behind === 1 ? '' : 's'}. Pull or rebase first.`
113
139
  )
package/src/index.mjs CHANGED
@@ -6,7 +6,10 @@ import chalk from 'chalk'
6
6
  import inquirer from 'inquirer'
7
7
  import { NodeSSH } from 'node-ssh'
8
8
 
9
- const RELEASE_FILE = 'release.json'
9
+ const PROJECT_CONFIG_DIR = '.zephyr'
10
+ const PROJECT_CONFIG_FILE = 'config.json'
11
+ const GLOBAL_CONFIG_DIR = path.join(os.homedir(), '.config', 'zephyr')
12
+ const SERVERS_FILE = path.join(GLOBAL_CONFIG_DIR, 'servers.json')
10
13
 
11
14
  const logProcessing = (message = '') => console.log(chalk.yellow(message))
12
15
  const logSuccess = (message = '') => console.log(chalk.green(message))
@@ -194,6 +197,33 @@ async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd())
194
197
  const initialStatus = await getGitStatus(rootDir)
195
198
  const hasPendingChanges = initialStatus.length > 0
196
199
 
200
+ const statusReport = await runCommandCapture('git', ['status', '--short', '--branch'], {
201
+ cwd: rootDir
202
+ })
203
+
204
+ const lines = statusReport.split(/\r?\n/)
205
+ const branchLine = lines[0] || ''
206
+ const aheadMatch = branchLine.match(/ahead (\d+)/)
207
+ const behindMatch = branchLine.match(/behind (\d+)/)
208
+ const aheadCount = aheadMatch ? parseInt(aheadMatch[1], 10) : 0
209
+ const behindCount = behindMatch ? parseInt(behindMatch[1], 10) : 0
210
+
211
+ if (aheadCount > 0) {
212
+ logWarning(`Local branch ${currentBranch} is ahead of upstream by ${aheadCount} commit${aheadCount === 1 ? '' : 's'}.`)
213
+ }
214
+
215
+ if (behindCount > 0) {
216
+ logProcessing(`Synchronizing local branch ${currentBranch} with its upstream...`)
217
+ try {
218
+ await runCommand('git', ['pull', '--ff-only'], { cwd: rootDir })
219
+ logSuccess('Local branch fast-forwarded with upstream changes.')
220
+ } catch (error) {
221
+ throw new Error(
222
+ `Unable to fast-forward ${currentBranch} with upstream changes. Resolve conflicts manually, then rerun the deployment.\n${error.message}`
223
+ )
224
+ }
225
+ }
226
+
197
227
  if (currentBranch !== targetBranch) {
198
228
  if (hasPendingChanges) {
199
229
  throw new Error(
@@ -245,6 +275,7 @@ async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd())
245
275
 
246
276
  async function ensureGitignoreEntry(rootDir) {
247
277
  const gitignorePath = path.join(rootDir, '.gitignore')
278
+ const targetEntry = `${PROJECT_CONFIG_DIR}/`
248
279
  let existingContent = ''
249
280
 
250
281
  try {
@@ -257,18 +288,18 @@ async function ensureGitignoreEntry(rootDir) {
257
288
 
258
289
  const hasEntry = existingContent
259
290
  .split(/\r?\n/)
260
- .some((line) => line.trim() === RELEASE_FILE)
291
+ .some((line) => line.trim() === targetEntry)
261
292
 
262
293
  if (hasEntry) {
263
294
  return
264
295
  }
265
296
 
266
297
  const updatedContent = existingContent
267
- ? `${existingContent.replace(/\s*$/, '')}\n${RELEASE_FILE}\n`
268
- : `${RELEASE_FILE}\n`
298
+ ? `${existingContent.replace(/\s*$/, '')}\n${targetEntry}\n`
299
+ : `${targetEntry}\n`
269
300
 
270
301
  await fs.writeFile(gitignorePath, updatedContent)
271
- logSuccess('Added release.json to .gitignore')
302
+ logSuccess('Added .zephyr/ to .gitignore')
272
303
 
273
304
  let isGitRepo = false
274
305
  try {
@@ -287,7 +318,7 @@ async function ensureGitignoreEntry(rootDir) {
287
318
 
288
319
  try {
289
320
  await runCommand('git', ['add', '.gitignore'], { cwd: rootDir })
290
- await runCommand('git', ['commit', '-m', 'chore: ignore release config'], { cwd: rootDir })
321
+ await runCommand('git', ['commit', '-m', 'chore: ignore zephyr config'], { cwd: rootDir })
291
322
  } catch (error) {
292
323
  if (error.exitCode === 1) {
293
324
  logWarning('Git commit skipped: nothing to commit or pre-commit hook prevented commit.')
@@ -297,9 +328,13 @@ async function ensureGitignoreEntry(rootDir) {
297
328
  }
298
329
  }
299
330
 
300
- async function loadReleases(filePath) {
331
+ async function ensureDirectory(dirPath) {
332
+ await fs.mkdir(dirPath, { recursive: true })
333
+ }
334
+
335
+ async function loadServers() {
301
336
  try {
302
- const raw = await fs.readFile(filePath, 'utf8')
337
+ const raw = await fs.readFile(SERVERS_FILE, 'utf8')
303
338
  const data = JSON.parse(raw)
304
339
  return Array.isArray(data) ? data : []
305
340
  } catch (error) {
@@ -307,14 +342,45 @@ async function loadReleases(filePath) {
307
342
  return []
308
343
  }
309
344
 
310
- logWarning('Failed to read release.json, starting with an empty list.')
345
+ logWarning('Failed to read servers.json, starting with an empty list.')
311
346
  return []
312
347
  }
313
348
  }
314
349
 
315
- async function saveReleases(filePath, releases) {
316
- const payload = JSON.stringify(releases, null, 2)
317
- await fs.writeFile(filePath, `${payload}\n`)
350
+ async function saveServers(servers) {
351
+ await ensureDirectory(GLOBAL_CONFIG_DIR)
352
+ const payload = JSON.stringify(servers, null, 2)
353
+ await fs.writeFile(SERVERS_FILE, `${payload}\n`)
354
+ }
355
+
356
+ function getProjectConfigPath(rootDir) {
357
+ return path.join(rootDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE)
358
+ }
359
+
360
+ async function loadProjectConfig(rootDir) {
361
+ const configPath = getProjectConfigPath(rootDir)
362
+
363
+ try {
364
+ const raw = await fs.readFile(configPath, 'utf8')
365
+ const data = JSON.parse(raw)
366
+ return {
367
+ apps: Array.isArray(data?.apps) ? data.apps : []
368
+ }
369
+ } catch (error) {
370
+ if (error.code === 'ENOENT') {
371
+ return { apps: [] }
372
+ }
373
+
374
+ logWarning('Failed to read .zephyr/config.json, starting with an empty list of apps.')
375
+ return { apps: [] }
376
+ }
377
+ }
378
+
379
+ async function saveProjectConfig(rootDir, config) {
380
+ const configDir = path.join(rootDir, PROJECT_CONFIG_DIR)
381
+ await ensureDirectory(configDir)
382
+ const payload = JSON.stringify({ apps: config.apps ?? [] }, null, 2)
383
+ await fs.writeFile(path.join(configDir, PROJECT_CONFIG_FILE), `${payload}\n`)
318
384
  }
319
385
 
320
386
  function defaultProjectPath(currentDir) {
@@ -719,14 +785,10 @@ async function runRemoteTasks(config) {
719
785
  }
720
786
  }
721
787
 
722
- async function collectServerConfig(currentDir) {
723
- const branches = await listGitBranches(currentDir)
724
- const defaultBranch = branches.includes('master') ? 'master' : branches[0]
788
+ async function promptServerDetails(existingServers = []) {
725
789
  const defaults = {
726
- serverName: 'home',
727
- serverIp: '1.1.1.1',
728
- projectPath: defaultProjectPath(currentDir),
729
- branch: defaultBranch
790
+ serverName: existingServers.length === 0 ? 'home' : `server-${existingServers.length + 1}`,
791
+ serverIp: '1.1.1.1'
730
792
  }
731
793
 
732
794
  const answers = await runPrompt([
@@ -739,19 +801,77 @@ async function collectServerConfig(currentDir) {
739
801
  {
740
802
  type: 'input',
741
803
  name: 'serverIp',
742
- message: 'Server IP',
804
+ message: 'Server IP address',
743
805
  default: defaults.serverIp
744
- },
806
+ }
807
+ ])
808
+
809
+ return {
810
+ serverName: answers.serverName.trim() || defaults.serverName,
811
+ serverIp: answers.serverIp.trim() || defaults.serverIp
812
+ }
813
+ }
814
+
815
+ async function selectServer(servers) {
816
+ if (servers.length === 0) {
817
+ logProcessing("No servers configured. Let's create one.")
818
+ const server = await promptServerDetails()
819
+ servers.push(server)
820
+ await saveServers(servers)
821
+ logSuccess('Saved server configuration to ~/.config/zephyr/servers.json')
822
+ return server
823
+ }
824
+
825
+ const choices = servers.map((server, index) => ({
826
+ name: `${server.serverName} (${server.serverIp})`,
827
+ value: index
828
+ }))
829
+
830
+ choices.push(new inquirer.Separator(), {
831
+ name: '➕ Register a new server',
832
+ value: 'create'
833
+ })
834
+
835
+ const { selection } = await runPrompt([
836
+ {
837
+ type: 'list',
838
+ name: 'selection',
839
+ message: 'Select server or register new',
840
+ choices,
841
+ default: 0
842
+ }
843
+ ])
844
+
845
+ if (selection === 'create') {
846
+ const server = await promptServerDetails(servers)
847
+ servers.push(server)
848
+ await saveServers(servers)
849
+ logSuccess('Appended server configuration to ~/.config/zephyr/servers.json')
850
+ return server
851
+ }
852
+
853
+ return servers[selection]
854
+ }
855
+
856
+ async function promptAppDetails(currentDir, existing = {}) {
857
+ const branches = await listGitBranches(currentDir)
858
+ const defaultBranch = existing.branch || (branches.includes('master') ? 'master' : branches[0])
859
+ const defaults = {
860
+ projectPath: existing.projectPath || defaultProjectPath(currentDir),
861
+ branch: defaultBranch
862
+ }
863
+
864
+ const answers = await runPrompt([
745
865
  {
746
866
  type: 'input',
747
867
  name: 'projectPath',
748
- message: 'Project path',
868
+ message: 'Remote project path',
749
869
  default: defaults.projectPath
750
870
  },
751
871
  {
752
872
  type: 'list',
753
873
  name: 'branchSelection',
754
- message: 'Branch',
874
+ message: 'Branch to deploy',
755
875
  choices: [
756
876
  ...branches.map((branch) => ({ name: branch, value: branch })),
757
877
  new inquirer.Separator(),
@@ -764,7 +884,7 @@ async function collectServerConfig(currentDir) {
764
884
  let branch = answers.branchSelection
765
885
 
766
886
  if (branch === '__custom') {
767
- const { customBranch } = await inquirer.prompt([
887
+ const { customBranch } = await runPrompt([
768
888
  {
769
889
  type: 'input',
770
890
  name: 'customBranch',
@@ -776,25 +896,41 @@ async function collectServerConfig(currentDir) {
776
896
  branch = customBranch.trim() || defaults.branch
777
897
  }
778
898
 
779
- const sshDetails = await promptSshDetails(currentDir)
899
+ const sshDetails = await promptSshDetails(currentDir, existing)
780
900
 
781
901
  return {
782
- serverName: answers.serverName,
783
- serverIp: answers.serverIp,
784
- projectPath: answers.projectPath,
902
+ projectPath: answers.projectPath.trim() || defaults.projectPath,
785
903
  branch,
786
904
  ...sshDetails
787
905
  }
788
906
  }
789
907
 
790
- async function promptSelection(releases) {
791
- const choices = releases.map((entry, index) => ({
792
- name: `${entry.serverName} (${entry.serverIp})` || `Server ${index + 1}`,
908
+ async function selectApp(projectConfig, server, currentDir) {
909
+ const apps = projectConfig.apps ?? []
910
+ const matches = apps
911
+ .map((app, index) => ({ app, index }))
912
+ .filter(({ app }) => app.serverName === server.serverName)
913
+
914
+ if (matches.length === 0) {
915
+ logProcessing(`No applications configured for ${server.serverName}. Let's create one.`)
916
+ const appDetails = await promptAppDetails(currentDir)
917
+ const appConfig = {
918
+ serverName: server.serverName,
919
+ ...appDetails
920
+ }
921
+ projectConfig.apps.push(appConfig)
922
+ await saveProjectConfig(currentDir, projectConfig)
923
+ logSuccess('Saved deployment configuration to .zephyr/config.json')
924
+ return appConfig
925
+ }
926
+
927
+ const choices = matches.map(({ app, index }) => ({
928
+ name: `${app.projectPath} (${app.branch})`,
793
929
  value: index
794
930
  }))
795
931
 
796
932
  choices.push(new inquirer.Separator(), {
797
- name: '➕ Create new deployment target',
933
+ name: '➕ Configure new application for this server',
798
934
  value: 'create'
799
935
  })
800
936
 
@@ -802,55 +938,58 @@ async function promptSelection(releases) {
802
938
  {
803
939
  type: 'list',
804
940
  name: 'selection',
805
- message: 'Select server or create new',
941
+ message: `Select application for ${server.serverName}`,
806
942
  choices,
807
943
  default: 0
808
944
  }
809
945
  ])
810
946
 
811
- return selection
947
+ if (selection === 'create') {
948
+ const appDetails = await promptAppDetails(currentDir)
949
+ const appConfig = {
950
+ serverName: server.serverName,
951
+ ...appDetails
952
+ }
953
+ projectConfig.apps.push(appConfig)
954
+ await saveProjectConfig(currentDir, projectConfig)
955
+ logSuccess('Appended deployment configuration to .zephyr/config.json')
956
+ return appConfig
957
+ }
958
+
959
+ const chosen = projectConfig.apps[selection]
960
+ return chosen
812
961
  }
813
962
 
814
963
  async function main() {
815
964
  const rootDir = process.cwd()
816
- const releasePath = path.join(rootDir, RELEASE_FILE)
817
965
 
818
966
  await ensureGitignoreEntry(rootDir)
819
967
 
820
- const releases = await loadReleases(releasePath)
968
+ const servers = await loadServers()
969
+ const server = await selectServer(servers)
970
+ const projectConfig = await loadProjectConfig(rootDir)
971
+ const appConfig = await selectApp(projectConfig, server, rootDir)
821
972
 
822
- if (releases.length === 0) {
823
- logProcessing("No deployment targets found. Let's create one.")
824
- const config = await collectServerConfig(rootDir)
825
- releases.push(config)
826
- await saveReleases(releasePath, releases)
827
- logSuccess('Saved deployment configuration to release.json')
828
- await runRemoteTasks(config)
829
- return
830
- }
973
+ const updated = await ensureSshDetails(appConfig, rootDir)
831
974
 
832
- const selection = await promptSelection(releases)
833
-
834
- if (selection === 'create') {
835
- const config = await collectServerConfig(rootDir)
836
- releases.push(config)
837
- await saveReleases(releasePath, releases)
838
- logSuccess('Appended new deployment configuration to release.json')
839
- await runRemoteTasks(config)
840
- return
975
+ if (updated) {
976
+ await saveProjectConfig(rootDir, projectConfig)
977
+ logSuccess('Updated .zephyr/config.json with SSH details.')
841
978
  }
842
979
 
843
- const chosen = releases[selection]
844
- const updated = await ensureSshDetails(chosen, rootDir)
845
-
846
- if (updated) {
847
- await saveReleases(releasePath, releases)
848
- logSuccess('Updated release.json with SSH details.')
980
+ const deploymentConfig = {
981
+ serverName: server.serverName,
982
+ serverIp: server.serverIp,
983
+ projectPath: appConfig.projectPath,
984
+ branch: appConfig.branch,
985
+ sshUser: appConfig.sshUser,
986
+ sshKey: appConfig.sshKey
849
987
  }
988
+
850
989
  logProcessing('\nSelected deployment target:')
851
- console.log(JSON.stringify(chosen, null, 2))
990
+ console.log(JSON.stringify(deploymentConfig, null, 2))
852
991
 
853
- await runRemoteTasks(chosen)
992
+ await runRemoteTasks(deploymentConfig)
854
993
  }
855
994
 
856
995
  export {
@@ -859,9 +998,14 @@ export {
859
998
  resolveRemotePath,
860
999
  isPrivateKeyFile,
861
1000
  runRemoteTasks,
862
- collectServerConfig,
1001
+ promptServerDetails,
1002
+ selectServer,
1003
+ promptAppDetails,
1004
+ selectApp,
863
1005
  promptSshDetails,
864
1006
  ensureSshDetails,
865
1007
  ensureLocalRepositoryState,
1008
+ loadServers,
1009
+ loadProjectConfig,
866
1010
  main
867
1011
  }
@@ -4,6 +4,7 @@ const mockReadFile = vi.fn()
4
4
  const mockReaddir = vi.fn()
5
5
  const mockAccess = vi.fn()
6
6
  const mockWriteFile = vi.fn()
7
+ const mockMkdir = vi.fn()
7
8
  const mockExecCommand = vi.fn()
8
9
  const mockConnect = vi.fn()
9
10
  const mockDispose = vi.fn()
@@ -14,12 +15,14 @@ vi.mock('node:fs/promises', () => ({
14
15
  readFile: mockReadFile,
15
16
  readdir: mockReaddir,
16
17
  access: mockAccess,
17
- writeFile: mockWriteFile
18
+ writeFile: mockWriteFile,
19
+ mkdir: mockMkdir
18
20
  },
19
21
  readFile: mockReadFile,
20
22
  readdir: mockReaddir,
21
23
  access: mockAccess,
22
- writeFile: mockWriteFile
24
+ writeFile: mockWriteFile,
25
+ mkdir: mockMkdir
23
26
  }))
24
27
 
25
28
  const spawnQueue = []
@@ -90,11 +93,18 @@ vi.mock('node:child_process', () => ({
90
93
  }
91
94
  }))
92
95
 
93
- vi.mock('inquirer', () => ({
94
- default: {
96
+ vi.mock('inquirer', () => {
97
+ class Separator {}
98
+
99
+ return {
100
+ default: {
101
+ prompt: mockPrompt,
102
+ Separator
103
+ },
104
+ Separator,
95
105
  prompt: mockPrompt
96
106
  }
97
- }))
107
+ })
98
108
 
99
109
  vi.mock('node-ssh', () => ({
100
110
  NodeSSH: vi.fn(() => ({
@@ -122,6 +132,7 @@ describe('zephyr deployment helpers', () => {
122
132
  mockReaddir.mockReset()
123
133
  mockAccess.mockReset()
124
134
  mockWriteFile.mockReset()
135
+ mockMkdir.mockReset()
125
136
  mockExecCommand.mockReset()
126
137
  mockConnect.mockReset()
127
138
  mockDispose.mockReset()
@@ -196,6 +207,52 @@ describe('zephyr deployment helpers', () => {
196
207
  })
197
208
  })
198
209
 
210
+ describe('configuration management', () => {
211
+ it('registers a new server when none exist', async () => {
212
+ mockPrompt.mockResolvedValueOnce({ serverName: 'production', serverIp: '203.0.113.10' })
213
+
214
+ const { selectServer } = await import('../src/index.mjs')
215
+
216
+ const servers = []
217
+ const server = await selectServer(servers)
218
+
219
+ expect(server).toEqual({ serverName: 'production', serverIp: '203.0.113.10' })
220
+ expect(servers).toHaveLength(1)
221
+ expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('.config/zephyr'), { recursive: true })
222
+ const [writePath, payload] = mockWriteFile.mock.calls.at(-1)
223
+ expect(writePath).toContain('servers.json')
224
+ expect(payload).toContain('production')
225
+ })
226
+
227
+ it('creates a new application configuration when none exist for a server', async () => {
228
+ queueSpawnResponse({ stdout: 'main\n' })
229
+ mockPrompt
230
+ .mockResolvedValueOnce({ projectPath: '~/webapps/demo', branchSelection: 'main' })
231
+ .mockResolvedValueOnce({ sshUser: 'forge', sshKeySelection: '/home/local/.ssh/id_rsa' })
232
+ mockReaddir.mockResolvedValue([])
233
+
234
+ const { selectApp } = await import('../src/index.mjs')
235
+
236
+ const projectConfig = { apps: [] }
237
+ const server = { serverName: 'production', serverIp: '203.0.113.10' }
238
+
239
+ const app = await selectApp(projectConfig, server, process.cwd())
240
+
241
+ expect(app).toMatchObject({
242
+ serverName: 'production',
243
+ projectPath: '~/webapps/demo',
244
+ branch: 'main',
245
+ sshUser: 'forge',
246
+ sshKey: '/home/local/.ssh/id_rsa'
247
+ })
248
+ expect(projectConfig.apps).toHaveLength(1)
249
+ expect(mockMkdir).toHaveBeenCalledWith(expect.stringContaining('.zephyr'), { recursive: true })
250
+ const [writePath, payload] = mockWriteFile.mock.calls.at(-1)
251
+ expect(writePath).toContain('.zephyr/config.json')
252
+ expect(payload).toContain('~/webapps/demo')
253
+ })
254
+ })
255
+
199
256
  it('schedules Laravel tasks based on diff', async () => {
200
257
  queueSpawnResponse({ stdout: 'main\n' })
201
258
  queueSpawnResponse({ stdout: '' })