@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.
- package/README.md +38 -9
- package/package.json +1 -1
- 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
|
|
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
|
-
|
|
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
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
|
|
420
|
-
const
|
|
421
|
-
|
|
422
|
-
const
|
|
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
|
-
|
|
425
|
-
|
|
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(
|
|
429
|
+
details = JSON.parse(checkResult.stdout.trim())
|
|
429
430
|
} catch (error) {
|
|
430
|
-
details = { raw:
|
|
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
|
-
|
|
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
|
|
456
|
-
const lockPath =
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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(
|
|
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.
|
|
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
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|