@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.
- package/package.json +1 -1
- package/src/index.mjs +571 -73
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
521
|
+
return JSON.parse(checkResult.stdout.trim())
|
|
430
522
|
} catch (error) {
|
|
431
|
-
|
|
523
|
+
return { raw: checkResult.stdout.trim() }
|
|
432
524
|
}
|
|
525
|
+
}
|
|
433
526
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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:
|
|
596
|
-
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
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
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
|
-
|
|
1311
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1384
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
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 {
|
|
1958
|
+
const { presetName } = await runPrompt([
|
|
1475
1959
|
{
|
|
1476
|
-
type: '
|
|
1477
|
-
name: '
|
|
1478
|
-
message: '
|
|
1479
|
-
default: isCreatingNewPreset
|
|
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
|
-
|
|
1484
|
-
|
|
1967
|
+
const trimmedName = presetName?.trim()
|
|
1968
|
+
|
|
1969
|
+
if (trimmedName && trimmedName.length > 0) {
|
|
1485
1970
|
const presets = projectConfig.presets ?? []
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
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
|
|