@wyxos/zephyr 0.1.2 → 0.1.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "vitest",
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))
@@ -245,6 +248,7 @@ async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd())
245
248
 
246
249
  async function ensureGitignoreEntry(rootDir) {
247
250
  const gitignorePath = path.join(rootDir, '.gitignore')
251
+ const targetEntry = `${PROJECT_CONFIG_DIR}/`
248
252
  let existingContent = ''
249
253
 
250
254
  try {
@@ -257,18 +261,18 @@ async function ensureGitignoreEntry(rootDir) {
257
261
 
258
262
  const hasEntry = existingContent
259
263
  .split(/\r?\n/)
260
- .some((line) => line.trim() === RELEASE_FILE)
264
+ .some((line) => line.trim() === targetEntry)
261
265
 
262
266
  if (hasEntry) {
263
267
  return
264
268
  }
265
269
 
266
270
  const updatedContent = existingContent
267
- ? `${existingContent.replace(/\s*$/, '')}\n${RELEASE_FILE}\n`
268
- : `${RELEASE_FILE}\n`
271
+ ? `${existingContent.replace(/\s*$/, '')}\n${targetEntry}\n`
272
+ : `${targetEntry}\n`
269
273
 
270
274
  await fs.writeFile(gitignorePath, updatedContent)
271
- logSuccess('Added release.json to .gitignore')
275
+ logSuccess('Added .zephyr/ to .gitignore')
272
276
 
273
277
  let isGitRepo = false
274
278
  try {
@@ -287,7 +291,7 @@ async function ensureGitignoreEntry(rootDir) {
287
291
 
288
292
  try {
289
293
  await runCommand('git', ['add', '.gitignore'], { cwd: rootDir })
290
- await runCommand('git', ['commit', '-m', 'chore: ignore release config'], { cwd: rootDir })
294
+ await runCommand('git', ['commit', '-m', 'chore: ignore zephyr config'], { cwd: rootDir })
291
295
  } catch (error) {
292
296
  if (error.exitCode === 1) {
293
297
  logWarning('Git commit skipped: nothing to commit or pre-commit hook prevented commit.')
@@ -297,9 +301,13 @@ async function ensureGitignoreEntry(rootDir) {
297
301
  }
298
302
  }
299
303
 
300
- async function loadReleases(filePath) {
304
+ async function ensureDirectory(dirPath) {
305
+ await fs.mkdir(dirPath, { recursive: true })
306
+ }
307
+
308
+ async function loadServers() {
301
309
  try {
302
- const raw = await fs.readFile(filePath, 'utf8')
310
+ const raw = await fs.readFile(SERVERS_FILE, 'utf8')
303
311
  const data = JSON.parse(raw)
304
312
  return Array.isArray(data) ? data : []
305
313
  } catch (error) {
@@ -307,14 +315,45 @@ async function loadReleases(filePath) {
307
315
  return []
308
316
  }
309
317
 
310
- logWarning('Failed to read release.json, starting with an empty list.')
318
+ logWarning('Failed to read servers.json, starting with an empty list.')
311
319
  return []
312
320
  }
313
321
  }
314
322
 
315
- async function saveReleases(filePath, releases) {
316
- const payload = JSON.stringify(releases, null, 2)
317
- await fs.writeFile(filePath, `${payload}\n`)
323
+ async function saveServers(servers) {
324
+ await ensureDirectory(GLOBAL_CONFIG_DIR)
325
+ const payload = JSON.stringify(servers, null, 2)
326
+ await fs.writeFile(SERVERS_FILE, `${payload}\n`)
327
+ }
328
+
329
+ function getProjectConfigPath(rootDir) {
330
+ return path.join(rootDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE)
331
+ }
332
+
333
+ async function loadProjectConfig(rootDir) {
334
+ const configPath = getProjectConfigPath(rootDir)
335
+
336
+ try {
337
+ const raw = await fs.readFile(configPath, 'utf8')
338
+ const data = JSON.parse(raw)
339
+ return {
340
+ apps: Array.isArray(data?.apps) ? data.apps : []
341
+ }
342
+ } catch (error) {
343
+ if (error.code === 'ENOENT') {
344
+ return { apps: [] }
345
+ }
346
+
347
+ logWarning('Failed to read .zephyr/config.json, starting with an empty list of apps.')
348
+ return { apps: [] }
349
+ }
350
+ }
351
+
352
+ async function saveProjectConfig(rootDir, config) {
353
+ const configDir = path.join(rootDir, PROJECT_CONFIG_DIR)
354
+ await ensureDirectory(configDir)
355
+ const payload = JSON.stringify({ apps: config.apps ?? [] }, null, 2)
356
+ await fs.writeFile(path.join(configDir, PROJECT_CONFIG_FILE), `${payload}\n`)
318
357
  }
319
358
 
320
359
  function defaultProjectPath(currentDir) {
@@ -719,14 +758,10 @@ async function runRemoteTasks(config) {
719
758
  }
720
759
  }
721
760
 
722
- async function collectServerConfig(currentDir) {
723
- const branches = await listGitBranches(currentDir)
724
- const defaultBranch = branches.includes('master') ? 'master' : branches[0]
761
+ async function promptServerDetails(existingServers = []) {
725
762
  const defaults = {
726
- serverName: 'home',
727
- serverIp: '1.1.1.1',
728
- projectPath: defaultProjectPath(currentDir),
729
- branch: defaultBranch
763
+ serverName: existingServers.length === 0 ? 'home' : `server-${existingServers.length + 1}`,
764
+ serverIp: '1.1.1.1'
730
765
  }
731
766
 
732
767
  const answers = await runPrompt([
@@ -739,19 +774,77 @@ async function collectServerConfig(currentDir) {
739
774
  {
740
775
  type: 'input',
741
776
  name: 'serverIp',
742
- message: 'Server IP',
777
+ message: 'Server IP address',
743
778
  default: defaults.serverIp
744
- },
779
+ }
780
+ ])
781
+
782
+ return {
783
+ serverName: answers.serverName.trim() || defaults.serverName,
784
+ serverIp: answers.serverIp.trim() || defaults.serverIp
785
+ }
786
+ }
787
+
788
+ async function selectServer(servers) {
789
+ if (servers.length === 0) {
790
+ logProcessing("No servers configured. Let's create one.")
791
+ const server = await promptServerDetails()
792
+ servers.push(server)
793
+ await saveServers(servers)
794
+ logSuccess('Saved server configuration to ~/.config/zephyr/servers.json')
795
+ return server
796
+ }
797
+
798
+ const choices = servers.map((server, index) => ({
799
+ name: `${server.serverName} (${server.serverIp})`,
800
+ value: index
801
+ }))
802
+
803
+ choices.push(new inquirer.Separator(), {
804
+ name: '➕ Register a new server',
805
+ value: 'create'
806
+ })
807
+
808
+ const { selection } = await runPrompt([
809
+ {
810
+ type: 'list',
811
+ name: 'selection',
812
+ message: 'Select server or register new',
813
+ choices,
814
+ default: 0
815
+ }
816
+ ])
817
+
818
+ if (selection === 'create') {
819
+ const server = await promptServerDetails(servers)
820
+ servers.push(server)
821
+ await saveServers(servers)
822
+ logSuccess('Appended server configuration to ~/.config/zephyr/servers.json')
823
+ return server
824
+ }
825
+
826
+ return servers[selection]
827
+ }
828
+
829
+ async function promptAppDetails(currentDir, existing = {}) {
830
+ const branches = await listGitBranches(currentDir)
831
+ const defaultBranch = existing.branch || (branches.includes('master') ? 'master' : branches[0])
832
+ const defaults = {
833
+ projectPath: existing.projectPath || defaultProjectPath(currentDir),
834
+ branch: defaultBranch
835
+ }
836
+
837
+ const answers = await runPrompt([
745
838
  {
746
839
  type: 'input',
747
840
  name: 'projectPath',
748
- message: 'Project path',
841
+ message: 'Remote project path',
749
842
  default: defaults.projectPath
750
843
  },
751
844
  {
752
845
  type: 'list',
753
846
  name: 'branchSelection',
754
- message: 'Branch',
847
+ message: 'Branch to deploy',
755
848
  choices: [
756
849
  ...branches.map((branch) => ({ name: branch, value: branch })),
757
850
  new inquirer.Separator(),
@@ -764,7 +857,7 @@ async function collectServerConfig(currentDir) {
764
857
  let branch = answers.branchSelection
765
858
 
766
859
  if (branch === '__custom') {
767
- const { customBranch } = await inquirer.prompt([
860
+ const { customBranch } = await runPrompt([
768
861
  {
769
862
  type: 'input',
770
863
  name: 'customBranch',
@@ -776,25 +869,41 @@ async function collectServerConfig(currentDir) {
776
869
  branch = customBranch.trim() || defaults.branch
777
870
  }
778
871
 
779
- const sshDetails = await promptSshDetails(currentDir)
872
+ const sshDetails = await promptSshDetails(currentDir, existing)
780
873
 
781
874
  return {
782
- serverName: answers.serverName,
783
- serverIp: answers.serverIp,
784
- projectPath: answers.projectPath,
875
+ projectPath: answers.projectPath.trim() || defaults.projectPath,
785
876
  branch,
786
877
  ...sshDetails
787
878
  }
788
879
  }
789
880
 
790
- async function promptSelection(releases) {
791
- const choices = releases.map((entry, index) => ({
792
- name: `${entry.serverName} (${entry.serverIp})` || `Server ${index + 1}`,
881
+ async function selectApp(projectConfig, server, currentDir) {
882
+ const apps = projectConfig.apps ?? []
883
+ const matches = apps
884
+ .map((app, index) => ({ app, index }))
885
+ .filter(({ app }) => app.serverName === server.serverName)
886
+
887
+ if (matches.length === 0) {
888
+ logProcessing(`No applications configured for ${server.serverName}. Let's create one.`)
889
+ const appDetails = await promptAppDetails(currentDir)
890
+ const appConfig = {
891
+ serverName: server.serverName,
892
+ ...appDetails
893
+ }
894
+ projectConfig.apps.push(appConfig)
895
+ await saveProjectConfig(currentDir, projectConfig)
896
+ logSuccess('Saved deployment configuration to .zephyr/config.json')
897
+ return appConfig
898
+ }
899
+
900
+ const choices = matches.map(({ app, index }) => ({
901
+ name: `${app.projectPath} (${app.branch})`,
793
902
  value: index
794
903
  }))
795
904
 
796
905
  choices.push(new inquirer.Separator(), {
797
- name: '➕ Create new deployment target',
906
+ name: '➕ Configure new application for this server',
798
907
  value: 'create'
799
908
  })
800
909
 
@@ -802,55 +911,58 @@ async function promptSelection(releases) {
802
911
  {
803
912
  type: 'list',
804
913
  name: 'selection',
805
- message: 'Select server or create new',
914
+ message: `Select application for ${server.serverName}`,
806
915
  choices,
807
916
  default: 0
808
917
  }
809
918
  ])
810
919
 
811
- return selection
920
+ if (selection === 'create') {
921
+ const appDetails = await promptAppDetails(currentDir)
922
+ const appConfig = {
923
+ serverName: server.serverName,
924
+ ...appDetails
925
+ }
926
+ projectConfig.apps.push(appConfig)
927
+ await saveProjectConfig(currentDir, projectConfig)
928
+ logSuccess('Appended deployment configuration to .zephyr/config.json')
929
+ return appConfig
930
+ }
931
+
932
+ const chosen = projectConfig.apps[selection]
933
+ return chosen
812
934
  }
813
935
 
814
936
  async function main() {
815
937
  const rootDir = process.cwd()
816
- const releasePath = path.join(rootDir, RELEASE_FILE)
817
938
 
818
939
  await ensureGitignoreEntry(rootDir)
819
940
 
820
- const releases = await loadReleases(releasePath)
941
+ const servers = await loadServers()
942
+ const server = await selectServer(servers)
943
+ const projectConfig = await loadProjectConfig(rootDir)
944
+ const appConfig = await selectApp(projectConfig, server, rootDir)
821
945
 
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
- }
831
-
832
- const selection = await promptSelection(releases)
946
+ const updated = await ensureSshDetails(appConfig, rootDir)
833
947
 
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
948
+ if (updated) {
949
+ await saveProjectConfig(rootDir, projectConfig)
950
+ logSuccess('Updated .zephyr/config.json with SSH details.')
841
951
  }
842
952
 
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.')
953
+ const deploymentConfig = {
954
+ serverName: server.serverName,
955
+ serverIp: server.serverIp,
956
+ projectPath: appConfig.projectPath,
957
+ branch: appConfig.branch,
958
+ sshUser: appConfig.sshUser,
959
+ sshKey: appConfig.sshKey
849
960
  }
961
+
850
962
  logProcessing('\nSelected deployment target:')
851
- console.log(JSON.stringify(chosen, null, 2))
963
+ console.log(JSON.stringify(deploymentConfig, null, 2))
852
964
 
853
- await runRemoteTasks(chosen)
965
+ await runRemoteTasks(deploymentConfig)
854
966
  }
855
967
 
856
968
  export {
@@ -859,9 +971,14 @@ export {
859
971
  resolveRemotePath,
860
972
  isPrivateKeyFile,
861
973
  runRemoteTasks,
862
- collectServerConfig,
974
+ promptServerDetails,
975
+ selectServer,
976
+ promptAppDetails,
977
+ selectApp,
863
978
  promptSshDetails,
864
979
  ensureSshDetails,
865
980
  ensureLocalRepositoryState,
981
+ loadServers,
982
+ loadProjectConfig,
866
983
  main
867
984
  }
@@ -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: '' })