@wyxos/zephyr 0.1.12 → 0.1.14

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 (3) hide show
  1. package/README.md +38 -9
  2. package/package.json +1 -1
  3. package/src/index.mjs +211 -45
package/README.md CHANGED
@@ -28,7 +28,7 @@ Follow the interactive prompts to configure your deployment target:
28
28
  - Git branch to deploy
29
29
  - SSH user and private key
30
30
 
31
- Configuration is saved to `release.json` for future deployments.
31
+ Configuration is saved automatically for future deployments.
32
32
 
33
33
  ## Features
34
34
 
@@ -40,35 +40,64 @@ Configuration is saved to `release.json` for future deployments.
40
40
  - Frontend asset compilation
41
41
  - Cache clearing and queue worker management
42
42
  - SSH key validation and management
43
+ - Deployment locking to prevent concurrent runs
44
+ - Task snapshots for resuming failed deployments
45
+ - Comprehensive logging of all remote operations
43
46
 
44
47
  ## Smart Task Execution
45
48
 
46
49
  Zephyr analyzes changed files and runs appropriate tasks:
47
50
 
48
51
  - **Always**: `git pull origin <branch>`
49
- - **Composer files changed**: `composer update`
50
- - **Migration files added**: `php artisan migrate`
52
+ - **Composer files changed**: `composer update --no-dev --no-interaction --prefer-dist`
53
+ - **Migration files added**: `php artisan migrate --force`
51
54
  - **package.json changed**: `npm install`
52
55
  - **Frontend files changed**: `npm run build`
53
56
  - **PHP files changed**: Clear Laravel caches, restart queues
54
57
 
55
58
  ## Configuration
56
59
 
57
- Deployment targets are stored in `release.json`:
60
+ ### Global Server Configuration
61
+
62
+ Servers are stored globally at `~/.config/zephyr/servers.json`:
58
63
 
59
64
  ```json
60
65
  [
61
66
  {
62
67
  "serverName": "production",
63
- "serverIp": "192.168.1.100",
64
- "projectPath": "~/webapps/myapp",
65
- "branch": "main",
66
- "sshUser": "forge",
67
- "sshKey": "~/.ssh/id_rsa"
68
+ "serverIp": "192.168.1.100"
68
69
  }
69
70
  ]
70
71
  ```
71
72
 
73
+ ### Project Configuration
74
+
75
+ Deployment targets are stored per-project at `.zephyr/config.json`:
76
+
77
+ ```json
78
+ {
79
+ "apps": [
80
+ {
81
+ "serverName": "production",
82
+ "projectPath": "~/webapps/myapp",
83
+ "branch": "main",
84
+ "sshUser": "forge",
85
+ "sshKey": "~/.ssh/id_rsa"
86
+ }
87
+ ]
88
+ }
89
+ ```
90
+
91
+ ### Project Directory Structure
92
+
93
+ Zephyr creates a `.zephyr/` directory in your project with:
94
+ - `config.json` - Project deployment configuration
95
+ - `deploy.lock` - Lock file to prevent concurrent deployments
96
+ - `pending-tasks.json` - Task snapshot for resuming failed deployments
97
+ - `{timestamp}.log` - Log files for each deployment run
98
+
99
+ The `.zephyr/` directory is automatically added to `.gitignore`.
100
+
72
101
  ## Requirements
73
102
 
74
103
  - Node.js 16+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
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
@@ -416,29 +416,26 @@ function getLockFilePath(rootDir) {
416
416
  return path.join(getProjectConfigDir(rootDir), PROJECT_LOCK_FILE)
417
417
  }
418
418
 
419
- async function acquireProjectLock(rootDir) {
420
- const lockDir = getProjectConfigDir(rootDir)
421
- await ensureDirectory(lockDir)
422
- const lockPath = getLockFilePath(rootDir)
419
+ async function acquireRemoteLock(ssh, remoteCwd) {
420
+ const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
421
+ const escapedLockPath = lockPath.replace(/'/g, "'\\''")
422
+ const checkCommand = `mkdir -p .zephyr && if [ -f '${escapedLockPath}' ]; then cat '${escapedLockPath}'; else echo "LOCK_NOT_FOUND"; fi`
423
423
 
424
- try {
425
- const existing = await fs.readFile(lockPath, 'utf8')
424
+ const checkResult = await ssh.execCommand(checkCommand, { cwd: remoteCwd })
425
+
426
+ if (checkResult.stdout && checkResult.stdout.trim() !== 'LOCK_NOT_FOUND' && checkResult.stdout.trim() !== '') {
426
427
  let details = {}
427
428
  try {
428
- details = JSON.parse(existing)
429
+ details = JSON.parse(checkResult.stdout.trim())
429
430
  } catch (error) {
430
- details = { raw: existing }
431
+ details = { raw: checkResult.stdout.trim() }
431
432
  }
432
433
 
433
434
  const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
434
435
  const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
435
436
  throw new Error(
436
- `Another deployment is currently in progress (started by ${startedBy}${startedAt}). Remove ${lockPath} if you are sure it is stale.`
437
+ `Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/${lockPath} if you are sure it is stale.`
437
438
  )
438
- } catch (error) {
439
- if (error.code !== 'ENOENT') {
440
- throw error
441
- }
442
439
  }
443
440
 
444
441
  const payload = {
@@ -448,18 +445,27 @@ async function acquireProjectLock(rootDir) {
448
445
  startedAt: new Date().toISOString()
449
446
  }
450
447
 
451
- await fs.writeFile(lockPath, `${JSON.stringify(payload, null, 2)}\n`)
448
+ const payloadJson = JSON.stringify(payload, null, 2)
449
+ const payloadBase64 = Buffer.from(payloadJson).toString('base64')
450
+ const createCommand = `mkdir -p .zephyr && echo '${payloadBase64}' | base64 --decode > '${escapedLockPath}'`
451
+
452
+ const createResult = await ssh.execCommand(createCommand, { cwd: remoteCwd })
453
+
454
+ if (createResult.code !== 0) {
455
+ throw new Error(`Failed to create lock file on server: ${createResult.stderr}`)
456
+ }
457
+
452
458
  return lockPath
453
459
  }
454
460
 
455
- async function releaseProjectLock(rootDir) {
456
- const lockPath = getLockFilePath(rootDir)
457
- try {
458
- await fs.unlink(lockPath)
459
- } catch (error) {
460
- if (error.code !== 'ENOENT') {
461
- throw error
462
- }
461
+ async function releaseRemoteLock(ssh, remoteCwd) {
462
+ const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
463
+ const escapedLockPath = lockPath.replace(/'/g, "'\\''")
464
+ const removeCommand = `rm -f '${escapedLockPath}'`
465
+
466
+ const result = await ssh.execCommand(removeCommand, { cwd: remoteCwd })
467
+ if (result.code !== 0 && result.code !== 1) {
468
+ logWarning(`Failed to remove lock file: ${result.stderr}`)
463
469
  }
464
470
  }
465
471
 
@@ -586,22 +592,30 @@ async function loadProjectConfig(rootDir) {
586
592
  const raw = await fs.readFile(configPath, 'utf8')
587
593
  const data = JSON.parse(raw)
588
594
  return {
589
- apps: Array.isArray(data?.apps) ? data.apps : []
595
+ apps: Array.isArray(data?.apps) ? data.apps : [],
596
+ presets: Array.isArray(data?.presets) ? data.presets : []
590
597
  }
591
598
  } catch (error) {
592
599
  if (error.code === 'ENOENT') {
593
- return { apps: [] }
600
+ return { apps: [], presets: [] }
594
601
  }
595
602
 
596
603
  logWarning('Failed to read .zephyr/config.json, starting with an empty list of apps.')
597
- return { apps: [] }
604
+ return { apps: [], presets: [] }
598
605
  }
599
606
  }
600
607
 
601
608
  async function saveProjectConfig(rootDir, config) {
602
609
  const configDir = path.join(rootDir, PROJECT_CONFIG_DIR)
603
610
  await ensureDirectory(configDir)
604
- const payload = JSON.stringify({ apps: config.apps ?? [] }, null, 2)
611
+ const payload = JSON.stringify(
612
+ {
613
+ apps: config.apps ?? [],
614
+ presets: config.presets ?? []
615
+ },
616
+ null,
617
+ 2
618
+ )
605
619
  await fs.writeFile(path.join(configDir, PROJECT_CONFIG_FILE), `${payload}\n`)
606
620
  }
607
621
 
@@ -795,11 +809,41 @@ function resolveRemotePath(projectPath, remoteHome) {
795
809
  return `${sanitizedHome}/${projectPath}`
796
810
  }
797
811
 
812
+ async function isLocalLaravelProject(rootDir) {
813
+ try {
814
+ const artisanPath = path.join(rootDir, 'artisan')
815
+ const composerPath = path.join(rootDir, 'composer.json')
816
+
817
+ await fs.access(artisanPath)
818
+ const composerContent = await fs.readFile(composerPath, 'utf8')
819
+ const composerJson = JSON.parse(composerContent)
820
+
821
+ return (
822
+ composerJson.require &&
823
+ typeof composerJson.require === 'object' &&
824
+ 'laravel/framework' in composerJson.require
825
+ )
826
+ } catch {
827
+ return false
828
+ }
829
+ }
830
+
798
831
  async function runRemoteTasks(config, options = {}) {
799
832
  const { snapshot = null, rootDir = process.cwd() } = options
800
833
 
801
834
  await ensureLocalRepositoryState(config.branch, rootDir)
802
835
 
836
+ 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}`)
844
+ }
845
+ }
846
+
803
847
  const ssh = createSshClient()
804
848
  const sshUser = config.sshUser || os.userInfo().username
805
849
  const privateKeyPath = await resolveSshKeyPath(config.sshKey)
@@ -807,6 +851,8 @@ async function runRemoteTasks(config, options = {}) {
807
851
 
808
852
  logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
809
853
 
854
+ let lockAcquired = false
855
+
810
856
  try {
811
857
  await ssh.connect({
812
858
  host: config.serverIp,
@@ -818,7 +864,10 @@ async function runRemoteTasks(config, options = {}) {
818
864
  const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
819
865
  const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
820
866
 
821
- logProcessing(`Connection established. Running deployment commands in ${remoteCwd}...`)
867
+ logProcessing(`Connection established. Acquiring deployment lock on server...`)
868
+ await acquireRemoteLock(ssh, remoteCwd)
869
+ lockAcquired = true
870
+ logProcessing(`Lock acquired. Running deployment commands in ${remoteCwd}...`)
822
871
 
823
872
  // Robust environment bootstrap that works even when profile files don't export PATH
824
873
  // for non-interactive shells. This handles:
@@ -1112,8 +1161,20 @@ async function runRemoteTasks(config, options = {}) {
1112
1161
  }
1113
1162
  throw new Error(`Deployment failed: ${error.message}`)
1114
1163
  } finally {
1164
+ if (lockAcquired && ssh) {
1165
+ try {
1166
+ const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
1167
+ const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
1168
+ const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
1169
+ await releaseRemoteLock(ssh, remoteCwd)
1170
+ } catch (error) {
1171
+ logWarning(`Failed to release lock: ${error.message}`)
1172
+ }
1173
+ }
1115
1174
  await closeLogFile()
1116
- ssh.dispose()
1175
+ if (ssh) {
1176
+ ssh.dispose()
1177
+ }
1117
1178
  }
1118
1179
  }
1119
1180
 
@@ -1244,6 +1305,12 @@ async function selectApp(projectConfig, server, currentDir) {
1244
1305
  .filter(({ app }) => app.serverName === server.serverName)
1245
1306
 
1246
1307
  if (matches.length === 0) {
1308
+ 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
+ )
1313
+ }
1247
1314
  logProcessing(`No applications configured for ${server.serverName}. Let's create one.`)
1248
1315
  const appDetails = await promptAppDetails(currentDir)
1249
1316
  const appConfig = {
@@ -1256,9 +1323,9 @@ async function selectApp(projectConfig, server, currentDir) {
1256
1323
  return appConfig
1257
1324
  }
1258
1325
 
1259
- const choices = matches.map(({ app, index }) => ({
1326
+ const choices = matches.map(({ app, index }, matchIndex) => ({
1260
1327
  name: `${app.projectPath} (${app.branch})`,
1261
- value: index
1328
+ value: matchIndex
1262
1329
  }))
1263
1330
 
1264
1331
  choices.push(new inquirer.Separator(), {
@@ -1288,20 +1355,101 @@ async function selectApp(projectConfig, server, currentDir) {
1288
1355
  return appConfig
1289
1356
  }
1290
1357
 
1291
- const chosen = projectConfig.apps[selection]
1358
+ const chosen = matches[selection].app
1292
1359
  return chosen
1293
1360
  }
1294
1361
 
1362
+ async function promptPresetName() {
1363
+ const { presetName } = await runPrompt([
1364
+ {
1365
+ type: 'input',
1366
+ name: 'presetName',
1367
+ message: 'Enter a name for this preset',
1368
+ validate: (value) => (value && value.trim().length > 0 ? true : 'Preset name cannot be empty.')
1369
+ }
1370
+ ])
1371
+
1372
+ return presetName.trim()
1373
+ }
1374
+
1375
+ async function selectPreset(projectConfig) {
1376
+ const presets = projectConfig.presets ?? []
1377
+
1378
+ if (presets.length === 0) {
1379
+ return null
1380
+ }
1381
+
1382
+ const choices = presets.map((preset, index) => ({
1383
+ name: `${preset.name} (${preset.serverName} → ${preset.projectPath} [${preset.branch}])`,
1384
+ value: index
1385
+ }))
1386
+
1387
+ choices.push(new inquirer.Separator(), {
1388
+ name: '➕ Create new preset',
1389
+ value: 'create'
1390
+ })
1391
+
1392
+ const { selection } = await runPrompt([
1393
+ {
1394
+ type: 'list',
1395
+ name: 'selection',
1396
+ message: 'Select preset or create new',
1397
+ choices,
1398
+ default: 0
1399
+ }
1400
+ ])
1401
+
1402
+ if (selection === 'create') {
1403
+ return 'create' // Return a special marker instead of null
1404
+ }
1405
+
1406
+ return presets[selection]
1407
+ }
1408
+
1295
1409
  async function main() {
1296
1410
  const rootDir = process.cwd()
1297
1411
 
1298
1412
  await ensureGitignoreEntry(rootDir)
1299
1413
  await ensureProjectReleaseScript(rootDir)
1300
1414
 
1301
- const servers = await loadServers()
1302
- const server = await selectServer(servers)
1303
1415
  const projectConfig = await loadProjectConfig(rootDir)
1304
- const appConfig = await selectApp(projectConfig, server, rootDir)
1416
+ let server = null
1417
+ let appConfig = null
1418
+ let isCreatingNewPreset = false
1419
+
1420
+ const preset = await selectPreset(projectConfig)
1421
+
1422
+ if (preset === 'create') {
1423
+ // User explicitly chose to create a new preset
1424
+ isCreatingNewPreset = true
1425
+ const servers = await loadServers()
1426
+ server = await selectServer(servers)
1427
+ appConfig = await selectApp(projectConfig, server, rootDir)
1428
+ } 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()
1436
+ server = await selectServer(servers)
1437
+ 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
+ }
1447
+ } else {
1448
+ // No presets exist, go through normal flow
1449
+ const servers = await loadServers()
1450
+ server = await selectServer(servers)
1451
+ appConfig = await selectApp(projectConfig, server, rootDir)
1452
+ }
1305
1453
 
1306
1454
  const updated = await ensureSshDetails(appConfig, rootDir)
1307
1455
 
@@ -1322,6 +1470,33 @@ async function main() {
1322
1470
  logProcessing('\nSelected deployment target:')
1323
1471
  console.log(JSON.stringify(deploymentConfig, null, 2))
1324
1472
 
1473
+ if (isCreatingNewPreset || !preset) {
1474
+ const { saveAsPreset } = await runPrompt([
1475
+ {
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
1480
+ }
1481
+ ])
1482
+
1483
+ if (saveAsPreset) {
1484
+ const presetName = await promptPresetName()
1485
+ 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`)
1497
+ }
1498
+ }
1499
+
1325
1500
  const existingSnapshot = await loadPendingTasksSnapshot(rootDir)
1326
1501
  let snapshotToUse = null
1327
1502
 
@@ -1358,17 +1533,7 @@ async function main() {
1358
1533
  }
1359
1534
  }
1360
1535
 
1361
- let lockAcquired = false
1362
-
1363
- try {
1364
- await acquireProjectLock(rootDir)
1365
- lockAcquired = true
1366
- await runRemoteTasks(deploymentConfig, { rootDir, snapshot: snapshotToUse })
1367
- } finally {
1368
- if (lockAcquired) {
1369
- await releaseProjectLock(rootDir)
1370
- }
1371
- }
1536
+ await runRemoteTasks(deploymentConfig, { rootDir, snapshot: snapshotToUse })
1372
1537
  }
1373
1538
 
1374
1539
  export {
@@ -1387,5 +1552,6 @@ export {
1387
1552
  ensureLocalRepositoryState,
1388
1553
  loadServers,
1389
1554
  loadProjectConfig,
1555
+ saveProjectConfig,
1390
1556
  main
1391
1557
  }