@wyxos/zephyr 0.1.14 → 0.2.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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.mjs +571 -73
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.1.14",
3
+ "version": "0.2.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",
package/src/index.mjs CHANGED
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
2
2
  import path from 'node:path'
3
3
  import { spawn } from 'node:child_process'
4
4
  import os from 'node:os'
5
+ import crypto from 'node:crypto'
5
6
  import chalk from 'chalk'
6
7
  import inquirer from 'inquirer'
7
8
  import { NodeSSH } from 'node-ssh'
@@ -47,6 +48,53 @@ async function closeLogFile() {
47
48
  logFilePath = null
48
49
  }
49
50
 
51
+ async function cleanupOldLogs(rootDir) {
52
+ const configDir = getProjectConfigDir(rootDir)
53
+
54
+ try {
55
+ const files = await fs.readdir(configDir)
56
+ const logFiles = files
57
+ .filter((file) => file.endsWith('.log'))
58
+ .map((file) => ({
59
+ name: file,
60
+ path: path.join(configDir, file)
61
+ }))
62
+
63
+ if (logFiles.length <= 3) {
64
+ return
65
+ }
66
+
67
+ // Get file stats and sort by modification time (newest first)
68
+ const filesWithStats = await Promise.all(
69
+ logFiles.map(async (file) => {
70
+ const stats = await fs.stat(file.path)
71
+ return {
72
+ ...file,
73
+ mtime: stats.mtime
74
+ }
75
+ })
76
+ )
77
+
78
+ filesWithStats.sort((a, b) => b.mtime - a.mtime)
79
+
80
+ // Keep the 3 newest, delete the rest
81
+ const filesToDelete = filesWithStats.slice(3)
82
+
83
+ for (const file of filesToDelete) {
84
+ try {
85
+ await fs.unlink(file.path)
86
+ } catch (error) {
87
+ // Ignore errors when deleting old logs
88
+ }
89
+ }
90
+ } catch (error) {
91
+ // Ignore errors during log cleanup
92
+ if (error.code !== 'ENOENT') {
93
+ // Only log if it's not a "directory doesn't exist" error
94
+ }
95
+ }
96
+ }
97
+
50
98
  const createSshClient = () => {
51
99
  if (typeof globalThis !== 'undefined' && globalThis.__zephyrSSHFactory) {
52
100
  return globalThis.__zephyrSSHFactory()
@@ -416,7 +464,52 @@ function getLockFilePath(rootDir) {
416
464
  return path.join(getProjectConfigDir(rootDir), PROJECT_LOCK_FILE)
417
465
  }
418
466
 
419
- async function acquireRemoteLock(ssh, remoteCwd) {
467
+ function createLockPayload() {
468
+ return {
469
+ user: os.userInfo().username,
470
+ pid: process.pid,
471
+ hostname: os.hostname(),
472
+ startedAt: new Date().toISOString()
473
+ }
474
+ }
475
+
476
+ async function acquireLocalLock(rootDir) {
477
+ const lockPath = getLockFilePath(rootDir)
478
+ const configDir = getProjectConfigDir(rootDir)
479
+ await ensureDirectory(configDir)
480
+
481
+ const payload = createLockPayload()
482
+ const payloadJson = JSON.stringify(payload, null, 2)
483
+ await fs.writeFile(lockPath, payloadJson, 'utf8')
484
+
485
+ return payload
486
+ }
487
+
488
+ async function releaseLocalLock(rootDir) {
489
+ const lockPath = getLockFilePath(rootDir)
490
+ try {
491
+ await fs.unlink(lockPath)
492
+ } catch (error) {
493
+ if (error.code !== 'ENOENT') {
494
+ logWarning(`Failed to remove local lock file: ${error.message}`)
495
+ }
496
+ }
497
+ }
498
+
499
+ async function readLocalLock(rootDir) {
500
+ const lockPath = getLockFilePath(rootDir)
501
+ try {
502
+ const content = await fs.readFile(lockPath, 'utf8')
503
+ return JSON.parse(content)
504
+ } catch (error) {
505
+ if (error.code === 'ENOENT') {
506
+ return null
507
+ }
508
+ throw error
509
+ }
510
+ }
511
+
512
+ async function readRemoteLock(ssh, remoteCwd) {
420
513
  const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
421
514
  const escapedLockPath = lockPath.replace(/'/g, "'\\''")
422
515
  const checkCommand = `mkdir -p .zephyr && if [ -f '${escapedLockPath}' ]; then cat '${escapedLockPath}'; else echo "LOCK_NOT_FOUND"; fi`
@@ -424,27 +517,100 @@ async function acquireRemoteLock(ssh, remoteCwd) {
424
517
  const checkResult = await ssh.execCommand(checkCommand, { cwd: remoteCwd })
425
518
 
426
519
  if (checkResult.stdout && checkResult.stdout.trim() !== 'LOCK_NOT_FOUND' && checkResult.stdout.trim() !== '') {
427
- let details = {}
428
520
  try {
429
- details = JSON.parse(checkResult.stdout.trim())
521
+ return JSON.parse(checkResult.stdout.trim())
430
522
  } catch (error) {
431
- details = { raw: checkResult.stdout.trim() }
523
+ return { raw: checkResult.stdout.trim() }
432
524
  }
525
+ }
433
526
 
434
- const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
435
- const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
436
- throw new Error(
437
- `Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/${lockPath} if you are sure it is stale.`
438
- )
527
+ return null
528
+ }
529
+
530
+ async function compareLocksAndPrompt(rootDir, ssh, remoteCwd) {
531
+ const localLock = await readLocalLock(rootDir)
532
+ const remoteLock = await readRemoteLock(ssh, remoteCwd)
533
+
534
+ if (!localLock || !remoteLock) {
535
+ return false
439
536
  }
440
537
 
441
- const payload = {
442
- user: os.userInfo().username,
443
- pid: process.pid,
444
- hostname: os.hostname(),
445
- startedAt: new Date().toISOString()
538
+ // Compare lock contents - if they match, it's likely stale
539
+ const localKey = `${localLock.user}@${localLock.hostname}:${localLock.pid}:${localLock.startedAt}`
540
+ const remoteKey = `${remoteLock.user}@${remoteLock.hostname}:${remoteLock.pid}:${remoteLock.startedAt}`
541
+
542
+ if (localKey === remoteKey) {
543
+ const startedBy = remoteLock.user ? `${remoteLock.user}@${remoteLock.hostname ?? 'unknown'}` : 'unknown user'
544
+ const startedAt = remoteLock.startedAt ? ` at ${remoteLock.startedAt}` : ''
545
+ const { shouldRemove } = await runPrompt([
546
+ {
547
+ type: 'confirm',
548
+ name: 'shouldRemove',
549
+ message: `Stale lock detected on server (started by ${startedBy}${startedAt}). This appears to be from a failed deployment. Remove it?`,
550
+ default: true
551
+ }
552
+ ])
553
+
554
+ if (shouldRemove) {
555
+ const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
556
+ const escapedLockPath = lockPath.replace(/'/g, "'\\''")
557
+ const removeCommand = `rm -f '${escapedLockPath}'`
558
+ await ssh.execCommand(removeCommand, { cwd: remoteCwd })
559
+ await releaseLocalLock(rootDir)
560
+ return true
561
+ }
446
562
  }
447
563
 
564
+ return false
565
+ }
566
+
567
+ async function acquireRemoteLock(ssh, remoteCwd, rootDir) {
568
+ const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
569
+ const escapedLockPath = lockPath.replace(/'/g, "'\\''")
570
+ const checkCommand = `mkdir -p .zephyr && if [ -f '${escapedLockPath}' ]; then cat '${escapedLockPath}'; else echo "LOCK_NOT_FOUND"; fi`
571
+
572
+ const checkResult = await ssh.execCommand(checkCommand, { cwd: remoteCwd })
573
+
574
+ if (checkResult.stdout && checkResult.stdout.trim() !== 'LOCK_NOT_FOUND' && checkResult.stdout.trim() !== '') {
575
+ // Check if we have a local lock and compare
576
+ const localLock = await readLocalLock(rootDir)
577
+ if (localLock) {
578
+ const removed = await compareLocksAndPrompt(rootDir, ssh, remoteCwd)
579
+ if (removed) {
580
+ // Lock was removed, continue to create new one
581
+ } else {
582
+ // User chose not to remove, throw error
583
+ let details = {}
584
+ try {
585
+ details = JSON.parse(checkResult.stdout.trim())
586
+ } catch (error) {
587
+ details = { raw: checkResult.stdout.trim() }
588
+ }
589
+
590
+ const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
591
+ const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
592
+ throw new Error(
593
+ `Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/${lockPath} if you are sure it is stale.`
594
+ )
595
+ }
596
+ } else {
597
+ // No local lock, but remote lock exists
598
+ let details = {}
599
+ try {
600
+ details = JSON.parse(checkResult.stdout.trim())
601
+ } catch (error) {
602
+ details = { raw: checkResult.stdout.trim() }
603
+ }
604
+
605
+ const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
606
+ const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
607
+ throw new Error(
608
+ `Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/${lockPath} if you are sure it is stale.`
609
+ )
610
+ }
611
+ }
612
+
613
+ const payload = createLockPayload()
448
614
  const payloadJson = JSON.stringify(payload, null, 2)
449
615
  const payloadBase64 = Buffer.from(payloadJson).toString('base64')
450
616
  const createCommand = `mkdir -p .zephyr && echo '${payloadBase64}' | base64 --decode > '${escapedLockPath}'`
@@ -455,6 +621,9 @@ async function acquireRemoteLock(ssh, remoteCwd) {
455
621
  throw new Error(`Failed to create lock file on server: ${createResult.stderr}`)
456
622
  }
457
623
 
624
+ // Create local lock as well
625
+ await acquireLocalLock(rootDir)
626
+
458
627
  return lockPath
459
628
  }
460
629
 
@@ -560,11 +729,115 @@ async function ensureDirectory(dirPath) {
560
729
  await fs.mkdir(dirPath, { recursive: true })
561
730
  }
562
731
 
732
+ function generateId() {
733
+ return crypto.randomBytes(8).toString('hex')
734
+ }
735
+
736
+ function migrateServers(servers) {
737
+ if (!Array.isArray(servers)) {
738
+ return []
739
+ }
740
+
741
+ let needsMigration = false
742
+ const migrated = servers.map((server) => {
743
+ if (!server.id) {
744
+ needsMigration = true
745
+ return {
746
+ ...server,
747
+ id: generateId()
748
+ }
749
+ }
750
+ return server
751
+ })
752
+
753
+ return { servers: migrated, needsMigration }
754
+ }
755
+
756
+ function migrateApps(apps, servers) {
757
+ if (!Array.isArray(apps)) {
758
+ return { apps: [], needsMigration: false }
759
+ }
760
+
761
+ // Create a map of serverName -> serverId for migration
762
+ const serverNameToId = new Map()
763
+ servers.forEach((server) => {
764
+ if (server.id && server.serverName) {
765
+ serverNameToId.set(server.serverName, server.id)
766
+ }
767
+ })
768
+
769
+ let needsMigration = false
770
+ const migrated = apps.map((app) => {
771
+ const updated = { ...app }
772
+
773
+ if (!app.id) {
774
+ needsMigration = true
775
+ updated.id = generateId()
776
+ }
777
+
778
+ // Migrate serverName to serverId if needed
779
+ if (app.serverName && !app.serverId) {
780
+ const serverId = serverNameToId.get(app.serverName)
781
+ if (serverId) {
782
+ needsMigration = true
783
+ updated.serverId = serverId
784
+ }
785
+ }
786
+
787
+ return updated
788
+ })
789
+
790
+ return { apps: migrated, needsMigration }
791
+ }
792
+
793
+ function migratePresets(presets, apps) {
794
+ if (!Array.isArray(presets)) {
795
+ return { presets: [], needsMigration: false }
796
+ }
797
+
798
+ // Create a map of serverName:projectPath -> appId for migration
799
+ const keyToAppId = new Map()
800
+ apps.forEach((app) => {
801
+ if (app.id && app.serverName && app.projectPath) {
802
+ const key = `${app.serverName}:${app.projectPath}`
803
+ keyToAppId.set(key, app.id)
804
+ }
805
+ })
806
+
807
+ let needsMigration = false
808
+ const migrated = presets.map((preset) => {
809
+ const updated = { ...preset }
810
+
811
+ // Migrate from key-based to appId-based if needed
812
+ if (preset.key && !preset.appId) {
813
+ const appId = keyToAppId.get(preset.key)
814
+ if (appId) {
815
+ needsMigration = true
816
+ updated.appId = appId
817
+ // Keep key for backward compatibility during transition, but it's deprecated
818
+ }
819
+ }
820
+
821
+ return updated
822
+ })
823
+
824
+ return { presets: migrated, needsMigration }
825
+ }
826
+
563
827
  async function loadServers() {
564
828
  try {
565
829
  const raw = await fs.readFile(SERVERS_FILE, 'utf8')
566
830
  const data = JSON.parse(raw)
567
- return Array.isArray(data) ? data : []
831
+ const servers = Array.isArray(data) ? data : []
832
+
833
+ const { servers: migrated, needsMigration } = migrateServers(servers)
834
+
835
+ if (needsMigration) {
836
+ await saveServers(migrated)
837
+ logSuccess('Migrated servers configuration to use unique IDs.')
838
+ }
839
+
840
+ return migrated
568
841
  } catch (error) {
569
842
  if (error.code === 'ENOENT') {
570
843
  return []
@@ -585,15 +858,32 @@ function getProjectConfigPath(rootDir) {
585
858
  return path.join(rootDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE)
586
859
  }
587
860
 
588
- async function loadProjectConfig(rootDir) {
861
+ async function loadProjectConfig(rootDir, servers = []) {
589
862
  const configPath = getProjectConfigPath(rootDir)
590
863
 
591
864
  try {
592
865
  const raw = await fs.readFile(configPath, 'utf8')
593
866
  const data = JSON.parse(raw)
867
+ const apps = Array.isArray(data?.apps) ? data.apps : []
868
+ const presets = Array.isArray(data?.presets) ? data.presets : []
869
+
870
+ // Migrate apps first (needs servers for serverName -> serverId mapping)
871
+ const { apps: migratedApps, needsMigration: appsNeedMigration } = migrateApps(apps, servers)
872
+
873
+ // Migrate presets (needs migrated apps for key -> appId mapping)
874
+ const { presets: migratedPresets, needsMigration: presetsNeedMigration } = migratePresets(presets, migratedApps)
875
+
876
+ if (appsNeedMigration || presetsNeedMigration) {
877
+ await saveProjectConfig(rootDir, {
878
+ apps: migratedApps,
879
+ presets: migratedPresets
880
+ })
881
+ logSuccess('Migrated project configuration to use unique IDs.')
882
+ }
883
+
594
884
  return {
595
- apps: Array.isArray(data?.apps) ? data.apps : [],
596
- presets: Array.isArray(data?.presets) ? data.presets : []
885
+ apps: migratedApps,
886
+ presets: migratedPresets
597
887
  }
598
888
  } catch (error) {
599
889
  if (error.code === 'ENOENT') {
@@ -809,6 +1099,92 @@ function resolveRemotePath(projectPath, remoteHome) {
809
1099
  return `${sanitizedHome}/${projectPath}`
810
1100
  }
811
1101
 
1102
+ async function hasPrePushHook(rootDir) {
1103
+ const hookPaths = [
1104
+ path.join(rootDir, '.git', 'hooks', 'pre-push'),
1105
+ path.join(rootDir, '.husky', 'pre-push'),
1106
+ path.join(rootDir, '.githooks', 'pre-push')
1107
+ ]
1108
+
1109
+ for (const hookPath of hookPaths) {
1110
+ try {
1111
+ await fs.access(hookPath)
1112
+ const stats = await fs.stat(hookPath)
1113
+ if (stats.isFile()) {
1114
+ return true
1115
+ }
1116
+ } catch {
1117
+ // Hook doesn't exist at this path, continue checking
1118
+ }
1119
+ }
1120
+
1121
+ return false
1122
+ }
1123
+
1124
+ async function hasLintScript(rootDir) {
1125
+ try {
1126
+ const packageJsonPath = path.join(rootDir, 'package.json')
1127
+ const raw = await fs.readFile(packageJsonPath, 'utf8')
1128
+ const packageJson = JSON.parse(raw)
1129
+ return packageJson.scripts && typeof packageJson.scripts.lint === 'string'
1130
+ } catch {
1131
+ return false
1132
+ }
1133
+ }
1134
+
1135
+ async function hasLaravelPint(rootDir) {
1136
+ try {
1137
+ const pintPath = path.join(rootDir, 'vendor', 'bin', 'pint')
1138
+ await fs.access(pintPath)
1139
+ const stats = await fs.stat(pintPath)
1140
+ return stats.isFile()
1141
+ } catch {
1142
+ return false
1143
+ }
1144
+ }
1145
+
1146
+ async function runLinting(rootDir) {
1147
+ const hasNpmLint = await hasLintScript(rootDir)
1148
+ const hasPint = await hasLaravelPint(rootDir)
1149
+
1150
+ if (hasNpmLint) {
1151
+ logProcessing('Running npm lint...')
1152
+ await runCommand('npm', ['run', 'lint'], { cwd: rootDir })
1153
+ logSuccess('Linting completed.')
1154
+ return true
1155
+ } else if (hasPint) {
1156
+ logProcessing('Running Laravel Pint...')
1157
+ await runCommand('php', ['vendor/bin/pint'], { cwd: rootDir })
1158
+ logSuccess('Linting completed.')
1159
+ return true
1160
+ }
1161
+
1162
+ return false
1163
+ }
1164
+
1165
+ async function hasUncommittedChanges(rootDir) {
1166
+ const status = await getGitStatus(rootDir)
1167
+ return status.length > 0
1168
+ }
1169
+
1170
+ async function commitLintingChanges(rootDir) {
1171
+ const status = await getGitStatus(rootDir)
1172
+
1173
+ if (!hasStagedChanges(status)) {
1174
+ // Stage only modified tracked files (not untracked files)
1175
+ await runCommand('git', ['add', '-u'], { cwd: rootDir })
1176
+ const newStatus = await getGitStatus(rootDir)
1177
+ if (!hasStagedChanges(newStatus)) {
1178
+ return false
1179
+ }
1180
+ }
1181
+
1182
+ logProcessing('Committing linting changes...')
1183
+ await runCommand('git', ['commit', '-m', 'style: apply linting fixes'], { cwd: rootDir })
1184
+ logSuccess('Linting changes committed.')
1185
+ return true
1186
+ }
1187
+
812
1188
  async function isLocalLaravelProject(rootDir) {
813
1189
  try {
814
1190
  const artisanPath = path.join(rootDir, 'artisan')
@@ -831,17 +1207,35 @@ async function isLocalLaravelProject(rootDir) {
831
1207
  async function runRemoteTasks(config, options = {}) {
832
1208
  const { snapshot = null, rootDir = process.cwd() } = options
833
1209
 
1210
+ await cleanupOldLogs(rootDir)
834
1211
  await ensureLocalRepositoryState(config.branch, rootDir)
835
1212
 
836
1213
  const isLaravel = await isLocalLaravelProject(rootDir)
837
- if (isLaravel) {
838
- logProcessing('Running Laravel tests locally...')
839
- try {
840
- await runCommand('php', ['artisan', 'test', '--compact'], { cwd: rootDir })
841
- logSuccess('Local tests passed.')
842
- } catch (error) {
843
- throw new Error(`Local tests failed. Fix test failures before deploying. ${error.message}`)
1214
+ const hasHook = await hasPrePushHook(rootDir)
1215
+
1216
+ if (!hasHook) {
1217
+ // Run linting before tests
1218
+ const lintRan = await runLinting(rootDir)
1219
+ if (lintRan) {
1220
+ // Check if linting made changes and commit them
1221
+ const hasChanges = await hasUncommittedChanges(rootDir)
1222
+ if (hasChanges) {
1223
+ await commitLintingChanges(rootDir)
1224
+ }
1225
+ }
1226
+
1227
+ // Run tests for Laravel projects
1228
+ if (isLaravel) {
1229
+ logProcessing('Running Laravel tests locally...')
1230
+ try {
1231
+ await runCommand('php', ['artisan', 'test'], { cwd: rootDir })
1232
+ logSuccess('Local tests passed.')
1233
+ } catch (error) {
1234
+ throw new Error(`Local tests failed. Fix test failures before deploying. ${error.message}`)
1235
+ }
844
1236
  }
1237
+ } else {
1238
+ logProcessing('Pre-push git hook detected. Skipping local linting and test execution.')
845
1239
  }
846
1240
 
847
1241
  const ssh = createSshClient()
@@ -865,7 +1259,7 @@ async function runRemoteTasks(config, options = {}) {
865
1259
  const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
866
1260
 
867
1261
  logProcessing(`Connection established. Acquiring deployment lock on server...`)
868
- await acquireRemoteLock(ssh, remoteCwd)
1262
+ await acquireRemoteLock(ssh, remoteCwd, rootDir)
869
1263
  lockAcquired = true
870
1264
  logProcessing(`Lock acquired. Running deployment commands in ${remoteCwd}...`)
871
1265
 
@@ -1159,6 +1553,19 @@ async function runRemoteTasks(config, options = {}) {
1159
1553
  if (logPath) {
1160
1554
  logError(`\nTask output has been logged to: ${logPath}`)
1161
1555
  }
1556
+
1557
+ // If lock was acquired but deployment failed, check for stale locks
1558
+ if (lockAcquired && ssh) {
1559
+ try {
1560
+ const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
1561
+ const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
1562
+ const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
1563
+ await compareLocksAndPrompt(rootDir, ssh, remoteCwd)
1564
+ } catch (lockError) {
1565
+ // Ignore lock comparison errors during error handling
1566
+ }
1567
+ }
1568
+
1162
1569
  throw new Error(`Deployment failed: ${error.message}`)
1163
1570
  } finally {
1164
1571
  if (lockAcquired && ssh) {
@@ -1167,6 +1574,7 @@ async function runRemoteTasks(config, options = {}) {
1167
1574
  const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
1168
1575
  const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
1169
1576
  await releaseRemoteLock(ssh, remoteCwd)
1577
+ await releaseLocalLock(rootDir)
1170
1578
  } catch (error) {
1171
1579
  logWarning(`Failed to release lock: ${error.message}`)
1172
1580
  }
@@ -1200,6 +1608,7 @@ async function promptServerDetails(existingServers = []) {
1200
1608
  ])
1201
1609
 
1202
1610
  return {
1611
+ id: generateId(),
1203
1612
  serverName: answers.serverName.trim() || defaults.serverName,
1204
1613
  serverIp: answers.serverIp.trim() || defaults.serverIp
1205
1614
  }
@@ -1302,18 +1711,22 @@ async function selectApp(projectConfig, server, currentDir) {
1302
1711
  const apps = projectConfig.apps ?? []
1303
1712
  const matches = apps
1304
1713
  .map((app, index) => ({ app, index }))
1305
- .filter(({ app }) => app.serverName === server.serverName)
1714
+ .filter(({ app }) => app.serverId === server.id || app.serverName === server.serverName)
1306
1715
 
1307
1716
  if (matches.length === 0) {
1308
1717
  if (apps.length > 0) {
1309
- const availableServers = [...new Set(apps.map((app) => app.serverName))]
1310
- logWarning(
1311
- `No applications configured for server "${server.serverName}". Available servers: ${availableServers.join(', ')}`
1312
- )
1718
+ const availableServers = [...new Set(apps.map((app) => app.serverName).filter(Boolean))]
1719
+ if (availableServers.length > 0) {
1720
+ logWarning(
1721
+ `No applications configured for server "${server.serverName}". Available servers: ${availableServers.join(', ')}`
1722
+ )
1723
+ }
1313
1724
  }
1314
1725
  logProcessing(`No applications configured for ${server.serverName}. Let's create one.`)
1315
1726
  const appDetails = await promptAppDetails(currentDir)
1316
1727
  const appConfig = {
1728
+ id: generateId(),
1729
+ serverId: server.id,
1317
1730
  serverName: server.serverName,
1318
1731
  ...appDetails
1319
1732
  }
@@ -1346,6 +1759,8 @@ async function selectApp(projectConfig, server, currentDir) {
1346
1759
  if (selection === 'create') {
1347
1760
  const appDetails = await promptAppDetails(currentDir)
1348
1761
  const appConfig = {
1762
+ id: generateId(),
1763
+ serverId: server.id,
1349
1764
  serverName: server.serverName,
1350
1765
  ...appDetails
1351
1766
  }
@@ -1372,17 +1787,44 @@ async function promptPresetName() {
1372
1787
  return presetName.trim()
1373
1788
  }
1374
1789
 
1375
- async function selectPreset(projectConfig) {
1790
+ function generatePresetKey(serverName, projectPath) {
1791
+ return `${serverName}:${projectPath}`
1792
+ }
1793
+
1794
+ async function selectPreset(projectConfig, servers) {
1376
1795
  const presets = projectConfig.presets ?? []
1796
+ const apps = projectConfig.apps ?? []
1377
1797
 
1378
1798
  if (presets.length === 0) {
1379
1799
  return null
1380
1800
  }
1381
1801
 
1382
- const choices = presets.map((preset, index) => ({
1383
- name: `${preset.name} (${preset.serverName} → ${preset.projectPath} [${preset.branch}])`,
1384
- value: index
1385
- }))
1802
+ const choices = presets.map((preset, index) => {
1803
+ let displayName = preset.name
1804
+
1805
+ if (preset.appId) {
1806
+ // New format: look up app by ID
1807
+ const app = apps.find((a) => a.id === preset.appId)
1808
+ if (app) {
1809
+ const server = servers.find((s) => s.id === app.serverId || s.serverName === app.serverName)
1810
+ const serverName = server?.serverName || 'unknown'
1811
+ const branch = preset.branch || app.branch || 'unknown'
1812
+ displayName = `${preset.name} (${serverName} → ${app.projectPath} [${branch}])`
1813
+ }
1814
+ } else if (preset.key) {
1815
+ // Legacy format: parse from key
1816
+ const keyParts = preset.key.split(':')
1817
+ const serverName = keyParts[0]
1818
+ const projectPath = keyParts[1]
1819
+ const branch = preset.branch || (keyParts.length === 3 ? keyParts[2] : 'unknown')
1820
+ displayName = `${preset.name} (${serverName} → ${projectPath} [${branch}])`
1821
+ }
1822
+
1823
+ return {
1824
+ name: displayName,
1825
+ value: index
1826
+ }
1827
+ })
1386
1828
 
1387
1829
  choices.push(new inquirer.Separator(), {
1388
1830
  name: '➕ Create new preset',
@@ -1412,41 +1854,83 @@ async function main() {
1412
1854
  await ensureGitignoreEntry(rootDir)
1413
1855
  await ensureProjectReleaseScript(rootDir)
1414
1856
 
1415
- const projectConfig = await loadProjectConfig(rootDir)
1857
+ // Load servers first (they may be migrated)
1858
+ const servers = await loadServers()
1859
+ // Load project config with servers for migration
1860
+ const projectConfig = await loadProjectConfig(rootDir, servers)
1861
+
1416
1862
  let server = null
1417
1863
  let appConfig = null
1418
1864
  let isCreatingNewPreset = false
1419
1865
 
1420
- const preset = await selectPreset(projectConfig)
1866
+ const preset = await selectPreset(projectConfig, servers)
1421
1867
 
1422
1868
  if (preset === 'create') {
1423
1869
  // User explicitly chose to create a new preset
1424
1870
  isCreatingNewPreset = true
1425
- const servers = await loadServers()
1426
1871
  server = await selectServer(servers)
1427
1872
  appConfig = await selectApp(projectConfig, server, rootDir)
1428
1873
  } else if (preset) {
1429
- // User selected an existing preset
1430
- const servers = await loadServers()
1431
- server = servers.find((s) => s.serverName === preset.serverName)
1432
-
1433
- if (!server) {
1434
- logWarning(`Preset references server "${preset.serverName}" which no longer exists. Creating new configuration.`)
1435
- const servers = await loadServers()
1874
+ // User selected an existing preset - look up by appId
1875
+ if (preset.appId) {
1876
+ appConfig = projectConfig.apps?.find((a) => a.id === preset.appId)
1877
+
1878
+ if (!appConfig) {
1879
+ logWarning(`Preset references app configuration that no longer exists. Creating new configuration.`)
1880
+ server = await selectServer(servers)
1881
+ appConfig = await selectApp(projectConfig, server, rootDir)
1882
+ } else {
1883
+ server = servers.find((s) => s.id === appConfig.serverId || s.serverName === appConfig.serverName)
1884
+
1885
+ if (!server) {
1886
+ logWarning(`Preset references server that no longer exists. Creating new configuration.`)
1887
+ server = await selectServer(servers)
1888
+ appConfig = await selectApp(projectConfig, server, rootDir)
1889
+ } else if (preset.branch && appConfig.branch !== preset.branch) {
1890
+ // Update branch if preset has a different branch
1891
+ appConfig.branch = preset.branch
1892
+ await saveProjectConfig(rootDir, projectConfig)
1893
+ logSuccess(`Updated branch to ${preset.branch} from preset.`)
1894
+ }
1895
+ }
1896
+ } else if (preset.key) {
1897
+ // Legacy preset format - migrate it
1898
+ const keyParts = preset.key.split(':')
1899
+ const serverName = keyParts[0]
1900
+ const projectPath = keyParts[1]
1901
+ const presetBranch = preset.branch || (keyParts.length === 3 ? keyParts[2] : null)
1902
+
1903
+ server = servers.find((s) => s.serverName === serverName)
1904
+
1905
+ if (!server) {
1906
+ logWarning(`Preset references server "${serverName}" which no longer exists. Creating new configuration.`)
1907
+ server = await selectServer(servers)
1908
+ appConfig = await selectApp(projectConfig, server, rootDir)
1909
+ } else {
1910
+ appConfig = projectConfig.apps?.find(
1911
+ (a) => (a.serverId === server.id || a.serverName === serverName) && a.projectPath === projectPath
1912
+ )
1913
+
1914
+ if (!appConfig) {
1915
+ logWarning(`Preset references app configuration that no longer exists. Creating new configuration.`)
1916
+ appConfig = await selectApp(projectConfig, server, rootDir)
1917
+ } else {
1918
+ // Migrate preset to use appId
1919
+ preset.appId = appConfig.id
1920
+ if (presetBranch && appConfig.branch !== presetBranch) {
1921
+ appConfig.branch = presetBranch
1922
+ }
1923
+ preset.branch = appConfig.branch
1924
+ await saveProjectConfig(rootDir, projectConfig)
1925
+ }
1926
+ }
1927
+ } else {
1928
+ logWarning(`Preset format is invalid. Creating new configuration.`)
1436
1929
  server = await selectServer(servers)
1437
1930
  appConfig = await selectApp(projectConfig, server, rootDir)
1438
- } else {
1439
- appConfig = {
1440
- serverName: preset.serverName,
1441
- projectPath: preset.projectPath,
1442
- branch: preset.branch,
1443
- sshUser: preset.sshUser,
1444
- sshKey: preset.sshKey
1445
- }
1446
1931
  }
1447
1932
  } else {
1448
1933
  // No presets exist, go through normal flow
1449
- const servers = await loadServers()
1450
1934
  server = await selectServer(servers)
1451
1935
  appConfig = await selectApp(projectConfig, server, rootDir)
1452
1936
  }
@@ -1471,29 +1955,43 @@ async function main() {
1471
1955
  console.log(JSON.stringify(deploymentConfig, null, 2))
1472
1956
 
1473
1957
  if (isCreatingNewPreset || !preset) {
1474
- const { saveAsPreset } = await runPrompt([
1958
+ const { presetName } = await runPrompt([
1475
1959
  {
1476
- type: 'confirm',
1477
- name: 'saveAsPreset',
1478
- message: 'Save this configuration as a preset?',
1479
- default: isCreatingNewPreset // Default to true if user explicitly chose to create preset
1960
+ type: 'input',
1961
+ name: 'presetName',
1962
+ message: 'Enter a name for this preset (leave blank to skip)',
1963
+ default: isCreatingNewPreset ? '' : undefined
1480
1964
  }
1481
1965
  ])
1482
1966
 
1483
- if (saveAsPreset) {
1484
- const presetName = await promptPresetName()
1967
+ const trimmedName = presetName?.trim()
1968
+
1969
+ if (trimmedName && trimmedName.length > 0) {
1485
1970
  const presets = projectConfig.presets ?? []
1486
- presets.push({
1487
- name: presetName,
1488
- serverName: deploymentConfig.serverName,
1489
- projectPath: deploymentConfig.projectPath,
1490
- branch: deploymentConfig.branch,
1491
- sshUser: deploymentConfig.sshUser,
1492
- sshKey: deploymentConfig.sshKey
1493
- })
1494
- projectConfig.presets = presets
1495
- await saveProjectConfig(rootDir, projectConfig)
1496
- logSuccess(`Saved preset "${presetName}" to .zephyr/config.json`)
1971
+
1972
+ // Find app config to get its ID
1973
+ const appId = appConfig.id
1974
+
1975
+ if (!appId) {
1976
+ logWarning('Cannot save preset: app configuration missing ID.')
1977
+ } else {
1978
+ // Check if preset with this appId already exists
1979
+ const existingIndex = presets.findIndex((p) => p.appId === appId)
1980
+ if (existingIndex >= 0) {
1981
+ presets[existingIndex].name = trimmedName
1982
+ presets[existingIndex].branch = deploymentConfig.branch
1983
+ } else {
1984
+ presets.push({
1985
+ name: trimmedName,
1986
+ appId: appId,
1987
+ branch: deploymentConfig.branch
1988
+ })
1989
+ }
1990
+
1991
+ projectConfig.presets = presets
1992
+ await saveProjectConfig(rootDir, projectConfig)
1993
+ logSuccess(`Saved preset "${trimmedName}" to .zephyr/config.json`)
1994
+ }
1497
1995
  }
1498
1996
  }
1499
1997