@wyxos/zephyr 0.2.0 → 0.2.2

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/src/index.mjs CHANGED
@@ -1,2055 +1,2092 @@
1
- import fs from 'node:fs/promises'
2
- import path from 'node:path'
3
- import { spawn } from 'node:child_process'
4
- import os from 'node:os'
5
- import crypto from 'node:crypto'
6
- import chalk from 'chalk'
7
- import inquirer from 'inquirer'
8
- import { NodeSSH } from 'node-ssh'
9
-
10
- const PROJECT_CONFIG_DIR = '.zephyr'
11
- const PROJECT_CONFIG_FILE = 'config.json'
12
- const GLOBAL_CONFIG_DIR = path.join(os.homedir(), '.config', 'zephyr')
13
- const SERVERS_FILE = path.join(GLOBAL_CONFIG_DIR, 'servers.json')
14
- const PROJECT_LOCK_FILE = 'deploy.lock'
15
- const PENDING_TASKS_FILE = 'pending-tasks.json'
16
- const RELEASE_SCRIPT_NAME = 'release'
17
- const RELEASE_SCRIPT_COMMAND = 'npx @wyxos/zephyr@latest'
18
-
19
- const logProcessing = (message = '') => console.log(chalk.yellow(message))
20
- const logSuccess = (message = '') => console.log(chalk.green(message))
21
- const logWarning = (message = '') => console.warn(chalk.yellow(message))
22
- const logError = (message = '') => console.error(chalk.red(message))
23
-
24
- let logFilePath = null
25
-
26
- async function getLogFilePath(rootDir) {
27
- if (logFilePath) {
28
- return logFilePath
29
- }
30
-
31
- const configDir = getProjectConfigDir(rootDir)
32
- await ensureDirectory(configDir)
33
-
34
- const now = new Date()
35
- const dateStr = now.toISOString().replace(/:/g, '-').replace(/\..+/, '')
36
- logFilePath = path.join(configDir, `${dateStr}.log`)
37
-
38
- return logFilePath
39
- }
40
-
41
- async function writeToLogFile(rootDir, message) {
42
- const logPath = await getLogFilePath(rootDir)
43
- const timestamp = new Date().toISOString()
44
- await fs.appendFile(logPath, `${timestamp} - ${message}\n`)
45
- }
46
-
47
- async function closeLogFile() {
48
- logFilePath = null
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
-
98
- const createSshClient = () => {
99
- if (typeof globalThis !== 'undefined' && globalThis.__zephyrSSHFactory) {
100
- return globalThis.__zephyrSSHFactory()
101
- }
102
-
103
- return new NodeSSH()
104
- }
105
-
106
- const runPrompt = async (questions) => {
107
- if (typeof globalThis !== 'undefined' && globalThis.__zephyrPrompt) {
108
- return globalThis.__zephyrPrompt(questions)
109
- }
110
-
111
- return inquirer.prompt(questions)
112
- }
113
-
114
- async function runCommand(command, args, { silent = false, cwd } = {}) {
115
- return new Promise((resolve, reject) => {
116
- const child = spawn(command, args, {
117
- stdio: silent ? 'ignore' : 'inherit',
118
- cwd
119
- })
120
-
121
- child.on('error', reject)
122
- child.on('close', (code) => {
123
- if (code === 0) {
124
- resolve()
125
- } else {
126
- const error = new Error(`${command} exited with code ${code}`)
127
- error.exitCode = code
128
- reject(error)
129
- }
130
- })
131
- })
132
- }
133
-
134
- async function runCommandCapture(command, args, { cwd } = {}) {
135
- return new Promise((resolve, reject) => {
136
- let stdout = ''
137
- let stderr = ''
138
-
139
- const child = spawn(command, args, {
140
- stdio: ['ignore', 'pipe', 'pipe'],
141
- cwd
142
- })
143
-
144
- child.stdout.on('data', (chunk) => {
145
- stdout += chunk
146
- })
147
-
148
- child.stderr.on('data', (chunk) => {
149
- stderr += chunk
150
- })
151
-
152
- child.on('error', reject)
153
- child.on('close', (code) => {
154
- if (code === 0) {
155
- resolve(stdout)
156
- } else {
157
- const error = new Error(`${command} exited with code ${code}: ${stderr.trim()}`)
158
- error.exitCode = code
159
- reject(error)
160
- }
161
- })
162
- })
163
- }
164
-
165
- async function getCurrentBranch(rootDir) {
166
- const output = await runCommandCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
167
- cwd: rootDir
168
- })
169
-
170
- return output.trim()
171
- }
172
-
173
- async function getGitStatus(rootDir) {
174
- const output = await runCommandCapture('git', ['status', '--porcelain'], {
175
- cwd: rootDir
176
- })
177
-
178
- return output.trim()
179
- }
180
-
181
- function hasStagedChanges(statusOutput) {
182
- if (!statusOutput || statusOutput.length === 0) {
183
- return false
184
- }
185
-
186
- const lines = statusOutput.split('\n').filter((line) => line.trim().length > 0)
187
-
188
- return lines.some((line) => {
189
- const firstChar = line[0]
190
- // In git status --porcelain format:
191
- // - First char is space: unstaged changes (e.g., " M file")
192
- // - First char is '?': untracked files (e.g., "?? file")
193
- // - First char is letter (M, A, D, etc.): staged changes (e.g., "M file")
194
- // Only return true for staged changes, not unstaged or untracked
195
- return firstChar && firstChar !== ' ' && firstChar !== '?'
196
- })
197
- }
198
-
199
- async function getUpstreamRef(rootDir) {
200
- try {
201
- const output = await runCommandCapture('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], {
202
- cwd: rootDir
203
- })
204
-
205
- const ref = output.trim()
206
- return ref.length > 0 ? ref : null
207
- } catch {
208
- return null
209
- }
210
- }
211
-
212
- async function ensureCommittedChangesPushed(targetBranch, rootDir) {
213
- const upstreamRef = await getUpstreamRef(rootDir)
214
-
215
- if (!upstreamRef) {
216
- logWarning(`Branch ${targetBranch} does not track a remote upstream; skipping automatic push of committed changes.`)
217
- return { pushed: false, upstreamRef: null }
218
- }
219
-
220
- const [remoteName, ...upstreamParts] = upstreamRef.split('/')
221
- const upstreamBranch = upstreamParts.join('/')
222
-
223
- if (!remoteName || !upstreamBranch) {
224
- logWarning(`Unable to determine remote destination for ${targetBranch}. Skipping automatic push.`)
225
- return { pushed: false, upstreamRef }
226
- }
227
-
228
- try {
229
- await runCommand('git', ['fetch', remoteName], { cwd: rootDir, silent: true })
230
- } catch (error) {
231
- logWarning(`Unable to fetch from ${remoteName} before push: ${error.message}`)
232
- }
233
-
234
- let remoteExists = true
235
-
236
- try {
237
- await runCommand('git', ['show-ref', '--verify', '--quiet', `refs/remotes/${upstreamRef}`], {
238
- cwd: rootDir,
239
- silent: true
240
- })
241
- } catch {
242
- remoteExists = false
243
- }
244
-
245
- let aheadCount = 0
246
- let behindCount = 0
247
-
248
- if (remoteExists) {
249
- const aheadOutput = await runCommandCapture('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
250
- cwd: rootDir
251
- })
252
-
253
- aheadCount = parseInt(aheadOutput.trim() || '0', 10)
254
-
255
- const behindOutput = await runCommandCapture('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
256
- cwd: rootDir
257
- })
258
-
259
- behindCount = parseInt(behindOutput.trim() || '0', 10)
260
- } else {
261
- aheadCount = 1
262
- }
263
-
264
- if (Number.isFinite(behindCount) && behindCount > 0) {
265
- throw new Error(
266
- `Local branch ${targetBranch} is behind ${upstreamRef} by ${behindCount} commit${behindCount === 1 ? '' : 's'}. Pull or rebase before deployment.`
267
- )
268
- }
269
-
270
- if (!Number.isFinite(aheadCount) || aheadCount <= 0) {
271
- return { pushed: false, upstreamRef }
272
- }
273
-
274
- const commitLabel = aheadCount === 1 ? 'commit' : 'commits'
275
- logProcessing(`Found ${aheadCount} ${commitLabel} not yet pushed to ${upstreamRef}. Pushing before deployment...`)
276
-
277
- await runCommand('git', ['push', remoteName, `${targetBranch}:${upstreamBranch}`], { cwd: rootDir })
278
- logSuccess(`Pushed committed changes to ${upstreamRef}.`)
279
-
280
- return { pushed: true, upstreamRef }
281
- }
282
-
283
- async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd()) {
284
- if (!targetBranch) {
285
- throw new Error('Deployment branch is not defined in the release configuration.')
286
- }
287
-
288
- const currentBranch = await getCurrentBranch(rootDir)
289
-
290
- if (!currentBranch) {
291
- throw new Error('Unable to determine the current git branch. Ensure this is a git repository.')
292
- }
293
-
294
- const initialStatus = await getGitStatus(rootDir)
295
- const hasPendingChanges = initialStatus.length > 0
296
-
297
- const statusReport = await runCommandCapture('git', ['status', '--short', '--branch'], {
298
- cwd: rootDir
299
- })
300
-
301
- const lines = statusReport.split(/\r?\n/)
302
- const branchLine = lines[0] || ''
303
- const aheadMatch = branchLine.match(/ahead (\d+)/)
304
- const behindMatch = branchLine.match(/behind (\d+)/)
305
- const aheadCount = aheadMatch ? parseInt(aheadMatch[1], 10) : 0
306
- const behindCount = behindMatch ? parseInt(behindMatch[1], 10) : 0
307
-
308
- if (aheadCount > 0) {
309
- logWarning(`Local branch ${currentBranch} is ahead of upstream by ${aheadCount} commit${aheadCount === 1 ? '' : 's'}.`)
310
- }
311
-
312
- if (behindCount > 0) {
313
- logProcessing(`Synchronizing local branch ${currentBranch} with its upstream...`)
314
- try {
315
- await runCommand('git', ['pull', '--ff-only'], { cwd: rootDir })
316
- logSuccess('Local branch fast-forwarded with upstream changes.')
317
- } catch (error) {
318
- throw new Error(
319
- `Unable to fast-forward ${currentBranch} with upstream changes. Resolve conflicts manually, then rerun the deployment.\n${error.message}`
320
- )
321
- }
322
- }
323
-
324
- if (currentBranch !== targetBranch) {
325
- if (hasPendingChanges) {
326
- throw new Error(
327
- `Local repository has uncommitted changes on ${currentBranch}. Commit or stash them before switching to ${targetBranch}.`
328
- )
329
- }
330
-
331
- logProcessing(`Switching local repository from ${currentBranch} to ${targetBranch}...`)
332
- await runCommand('git', ['checkout', targetBranch], { cwd: rootDir })
333
- logSuccess(`Checked out ${targetBranch} locally.`)
334
- }
335
-
336
- const statusAfterCheckout = currentBranch === targetBranch ? initialStatus : await getGitStatus(rootDir)
337
-
338
- if (statusAfterCheckout.length === 0) {
339
- await ensureCommittedChangesPushed(targetBranch, rootDir)
340
- logProcessing('Local repository is clean. Proceeding with deployment.')
341
- return
342
- }
343
-
344
- if (!hasStagedChanges(statusAfterCheckout)) {
345
- await ensureCommittedChangesPushed(targetBranch, rootDir)
346
- logProcessing('No staged changes detected. Unstaged or untracked files will not affect deployment. Proceeding with deployment.')
347
- return
348
- }
349
-
350
- logWarning(`Staged changes detected on ${targetBranch}. A commit is required before deployment.`)
351
-
352
- const { commitMessage } = await runPrompt([
353
- {
354
- type: 'input',
355
- name: 'commitMessage',
356
- message: 'Enter a commit message for pending changes before deployment',
357
- validate: (value) => (value && value.trim().length > 0 ? true : 'Commit message cannot be empty.')
358
- }
359
- ])
360
-
361
- const message = commitMessage.trim()
362
-
363
- logProcessing('Committing staged changes before deployment...')
364
- await runCommand('git', ['commit', '-m', message], { cwd: rootDir })
365
- await runCommand('git', ['push', 'origin', targetBranch], { cwd: rootDir })
366
- logSuccess(`Committed and pushed changes to origin/${targetBranch}.`)
367
-
368
- const finalStatus = await getGitStatus(rootDir)
369
-
370
- if (finalStatus.length > 0) {
371
- throw new Error('Local repository still has uncommitted changes after commit. Aborting deployment.')
372
- }
373
-
374
- await ensureCommittedChangesPushed(targetBranch, rootDir)
375
- logProcessing('Local repository is clean after committing pending changes.')
376
- }
377
-
378
- async function ensureProjectReleaseScript(rootDir) {
379
- const packageJsonPath = path.join(rootDir, 'package.json')
380
-
381
- let raw
382
- try {
383
- raw = await fs.readFile(packageJsonPath, 'utf8')
384
- } catch (error) {
385
- if (error.code === 'ENOENT') {
386
- return false
387
- }
388
-
389
- throw error
390
- }
391
-
392
- let packageJson
393
- try {
394
- packageJson = JSON.parse(raw)
395
- } catch (error) {
396
- logWarning('Unable to parse package.json; skipping release script injection.')
397
- return false
398
- }
399
-
400
- const currentCommand = packageJson?.scripts?.[RELEASE_SCRIPT_NAME]
401
-
402
- if (currentCommand && currentCommand.includes('@wyxos/zephyr')) {
403
- return false
404
- }
405
-
406
- const { installReleaseScript } = await runPrompt([
407
- {
408
- type: 'confirm',
409
- name: 'installReleaseScript',
410
- message: 'Add "release" script to package.json that runs "npx @wyxos/zephyr@latest"?',
411
- default: true
412
- }
413
- ])
414
-
415
- if (!installReleaseScript) {
416
- return false
417
- }
418
-
419
- if (!packageJson.scripts || typeof packageJson.scripts !== 'object') {
420
- packageJson.scripts = {}
421
- }
422
-
423
- packageJson.scripts[RELEASE_SCRIPT_NAME] = RELEASE_SCRIPT_COMMAND
424
-
425
- const updatedPayload = `${JSON.stringify(packageJson, null, 2)}\n`
426
- await fs.writeFile(packageJsonPath, updatedPayload)
427
- logSuccess('Added release script to package.json.')
428
-
429
- let isGitRepo = false
430
-
431
- try {
432
- await runCommand('git', ['rev-parse', '--is-inside-work-tree'], { cwd: rootDir, silent: true })
433
- isGitRepo = true
434
- } catch (error) {
435
- logWarning('Not a git repository; skipping commit for release script addition.')
436
- }
437
-
438
- if (isGitRepo) {
439
- try {
440
- await runCommand('git', ['add', 'package.json'], { cwd: rootDir, silent: true })
441
- await runCommand('git', ['commit', '-m', 'chore: add zephyr release script'], { cwd: rootDir, silent: true })
442
- logSuccess('Committed package.json release script addition.')
443
- } catch (error) {
444
- if (error.exitCode === 1) {
445
- logWarning('Git commit skipped: nothing to commit or pre-commit hook prevented commit.')
446
- } else {
447
- throw error
448
- }
449
- }
450
- }
451
-
452
- return true
453
- }
454
-
455
- function getProjectConfigDir(rootDir) {
456
- return path.join(rootDir, PROJECT_CONFIG_DIR)
457
- }
458
-
459
- function getPendingTasksPath(rootDir) {
460
- return path.join(getProjectConfigDir(rootDir), PENDING_TASKS_FILE)
461
- }
462
-
463
- function getLockFilePath(rootDir) {
464
- return path.join(getProjectConfigDir(rootDir), PROJECT_LOCK_FILE)
465
- }
466
-
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) {
513
- const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
514
- const escapedLockPath = lockPath.replace(/'/g, "'\\''")
515
- const checkCommand = `mkdir -p .zephyr && if [ -f '${escapedLockPath}' ]; then cat '${escapedLockPath}'; else echo "LOCK_NOT_FOUND"; fi`
516
-
517
- const checkResult = await ssh.execCommand(checkCommand, { cwd: remoteCwd })
518
-
519
- if (checkResult.stdout && checkResult.stdout.trim() !== 'LOCK_NOT_FOUND' && checkResult.stdout.trim() !== '') {
520
- try {
521
- return JSON.parse(checkResult.stdout.trim())
522
- } catch (error) {
523
- return { raw: checkResult.stdout.trim() }
524
- }
525
- }
526
-
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
536
- }
537
-
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
- }
562
- }
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()
614
- const payloadJson = JSON.stringify(payload, null, 2)
615
- const payloadBase64 = Buffer.from(payloadJson).toString('base64')
616
- const createCommand = `mkdir -p .zephyr && echo '${payloadBase64}' | base64 --decode > '${escapedLockPath}'`
617
-
618
- const createResult = await ssh.execCommand(createCommand, { cwd: remoteCwd })
619
-
620
- if (createResult.code !== 0) {
621
- throw new Error(`Failed to create lock file on server: ${createResult.stderr}`)
622
- }
623
-
624
- // Create local lock as well
625
- await acquireLocalLock(rootDir)
626
-
627
- return lockPath
628
- }
629
-
630
- async function releaseRemoteLock(ssh, remoteCwd) {
631
- const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
632
- const escapedLockPath = lockPath.replace(/'/g, "'\\''")
633
- const removeCommand = `rm -f '${escapedLockPath}'`
634
-
635
- const result = await ssh.execCommand(removeCommand, { cwd: remoteCwd })
636
- if (result.code !== 0 && result.code !== 1) {
637
- logWarning(`Failed to remove lock file: ${result.stderr}`)
638
- }
639
- }
640
-
641
- async function loadPendingTasksSnapshot(rootDir) {
642
- const snapshotPath = getPendingTasksPath(rootDir)
643
-
644
- try {
645
- const raw = await fs.readFile(snapshotPath, 'utf8')
646
- return JSON.parse(raw)
647
- } catch (error) {
648
- if (error.code === 'ENOENT') {
649
- return null
650
- }
651
-
652
- throw error
653
- }
654
- }
655
-
656
- async function savePendingTasksSnapshot(rootDir, snapshot) {
657
- const configDir = getProjectConfigDir(rootDir)
658
- await ensureDirectory(configDir)
659
- const payload = `${JSON.stringify(snapshot, null, 2)}\n`
660
- await fs.writeFile(getPendingTasksPath(rootDir), payload)
661
- }
662
-
663
- async function clearPendingTasksSnapshot(rootDir) {
664
- try {
665
- await fs.unlink(getPendingTasksPath(rootDir))
666
- } catch (error) {
667
- if (error.code !== 'ENOENT') {
668
- throw error
669
- }
670
- }
671
- }
672
-
673
- async function ensureGitignoreEntry(rootDir) {
674
- const gitignorePath = path.join(rootDir, '.gitignore')
675
- const targetEntry = `${PROJECT_CONFIG_DIR}/`
676
- let existingContent = ''
677
-
678
- try {
679
- existingContent = await fs.readFile(gitignorePath, 'utf8')
680
- } catch (error) {
681
- if (error.code !== 'ENOENT') {
682
- throw error
683
- }
684
- }
685
-
686
- const hasEntry = existingContent
687
- .split(/\r?\n/)
688
- .some((line) => line.trim() === targetEntry)
689
-
690
- if (hasEntry) {
691
- return
692
- }
693
-
694
- const updatedContent = existingContent
695
- ? `${existingContent.replace(/\s*$/, '')}\n${targetEntry}\n`
696
- : `${targetEntry}\n`
697
-
698
- await fs.writeFile(gitignorePath, updatedContent)
699
- logSuccess('Added .zephyr/ to .gitignore')
700
-
701
- let isGitRepo = false
702
- try {
703
- await runCommand('git', ['rev-parse', '--is-inside-work-tree'], {
704
- silent: true,
705
- cwd: rootDir
706
- })
707
- isGitRepo = true
708
- } catch (error) {
709
- logWarning('Not a git repository; skipping commit for .gitignore update.')
710
- }
711
-
712
- if (!isGitRepo) {
713
- return
714
- }
715
-
716
- try {
717
- await runCommand('git', ['add', '.gitignore'], { cwd: rootDir })
718
- await runCommand('git', ['commit', '-m', 'chore: ignore zephyr config'], { cwd: rootDir })
719
- } catch (error) {
720
- if (error.exitCode === 1) {
721
- logWarning('Git commit skipped: nothing to commit or pre-commit hook prevented commit.')
722
- } else {
723
- throw error
724
- }
725
- }
726
- }
727
-
728
- async function ensureDirectory(dirPath) {
729
- await fs.mkdir(dirPath, { recursive: true })
730
- }
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
-
827
- async function loadServers() {
828
- try {
829
- const raw = await fs.readFile(SERVERS_FILE, 'utf8')
830
- const data = JSON.parse(raw)
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
841
- } catch (error) {
842
- if (error.code === 'ENOENT') {
843
- return []
844
- }
845
-
846
- logWarning('Failed to read servers.json, starting with an empty list.')
847
- return []
848
- }
849
- }
850
-
851
- async function saveServers(servers) {
852
- await ensureDirectory(GLOBAL_CONFIG_DIR)
853
- const payload = JSON.stringify(servers, null, 2)
854
- await fs.writeFile(SERVERS_FILE, `${payload}\n`)
855
- }
856
-
857
- function getProjectConfigPath(rootDir) {
858
- return path.join(rootDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE)
859
- }
860
-
861
- async function loadProjectConfig(rootDir, servers = []) {
862
- const configPath = getProjectConfigPath(rootDir)
863
-
864
- try {
865
- const raw = await fs.readFile(configPath, 'utf8')
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
-
884
- return {
885
- apps: migratedApps,
886
- presets: migratedPresets
887
- }
888
- } catch (error) {
889
- if (error.code === 'ENOENT') {
890
- return { apps: [], presets: [] }
891
- }
892
-
893
- logWarning('Failed to read .zephyr/config.json, starting with an empty list of apps.')
894
- return { apps: [], presets: [] }
895
- }
896
- }
897
-
898
- async function saveProjectConfig(rootDir, config) {
899
- const configDir = path.join(rootDir, PROJECT_CONFIG_DIR)
900
- await ensureDirectory(configDir)
901
- const payload = JSON.stringify(
902
- {
903
- apps: config.apps ?? [],
904
- presets: config.presets ?? []
905
- },
906
- null,
907
- 2
908
- )
909
- await fs.writeFile(path.join(configDir, PROJECT_CONFIG_FILE), `${payload}\n`)
910
- }
911
-
912
- function defaultProjectPath(currentDir) {
913
- return `~/webapps/${path.basename(currentDir)}`
914
- }
915
-
916
- async function listGitBranches(currentDir) {
917
- try {
918
- const output = await runCommandCapture(
919
- 'git',
920
- ['branch', '--format', '%(refname:short)'],
921
- { cwd: currentDir }
922
- )
923
-
924
- const branches = output
925
- .split(/\r?\n/)
926
- .map((line) => line.trim())
927
- .filter(Boolean)
928
-
929
- return branches.length ? branches : ['master']
930
- } catch (error) {
931
- logWarning('Unable to read git branches; defaulting to master.')
932
- return ['master']
933
- }
934
- }
935
-
936
- async function listSshKeys() {
937
- const sshDir = path.join(os.homedir(), '.ssh')
938
-
939
- try {
940
- const entries = await fs.readdir(sshDir, { withFileTypes: true })
941
-
942
- const candidates = entries
943
- .filter((entry) => entry.isFile())
944
- .map((entry) => entry.name)
945
- .filter((name) => {
946
- if (!name) return false
947
- if (name.startsWith('.')) return false
948
- if (name.endsWith('.pub')) return false
949
- if (name.startsWith('known_hosts')) return false
950
- if (name === 'config') return false
951
- return name.trim().length > 0
952
- })
953
-
954
- const keys = []
955
-
956
- for (const name of candidates) {
957
- const filePath = path.join(sshDir, name)
958
- if (await isPrivateKeyFile(filePath)) {
959
- keys.push(name)
960
- }
961
- }
962
-
963
- return {
964
- sshDir,
965
- keys
966
- }
967
- } catch (error) {
968
- if (error.code === 'ENOENT') {
969
- return {
970
- sshDir,
971
- keys: []
972
- }
973
- }
974
-
975
- throw error
976
- }
977
- }
978
-
979
- async function isPrivateKeyFile(filePath) {
980
- try {
981
- const content = await fs.readFile(filePath, 'utf8')
982
- return /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(content)
983
- } catch (error) {
984
- return false
985
- }
986
- }
987
-
988
- async function promptSshDetails(currentDir, existing = {}) {
989
- const { sshDir, keys: sshKeys } = await listSshKeys()
990
- const defaultUser = existing.sshUser || os.userInfo().username
991
- const fallbackKey = path.join(sshDir, 'id_rsa')
992
- const preselectedKey = existing.sshKey || (sshKeys.length ? path.join(sshDir, sshKeys[0]) : fallbackKey)
993
-
994
- const sshKeyPrompt = sshKeys.length
995
- ? {
996
- type: 'list',
997
- name: 'sshKeySelection',
998
- message: 'SSH key',
999
- choices: [
1000
- ...sshKeys.map((key) => ({ name: key, value: path.join(sshDir, key) })),
1001
- new inquirer.Separator(),
1002
- { name: 'Enter custom SSH key path…', value: '__custom' }
1003
- ],
1004
- default: preselectedKey
1005
- }
1006
- : {
1007
- type: 'input',
1008
- name: 'sshKeySelection',
1009
- message: 'SSH key path',
1010
- default: preselectedKey
1011
- }
1012
-
1013
- const answers = await runPrompt([
1014
- {
1015
- type: 'input',
1016
- name: 'sshUser',
1017
- message: 'SSH user',
1018
- default: defaultUser
1019
- },
1020
- sshKeyPrompt
1021
- ])
1022
-
1023
- let sshKey = answers.sshKeySelection
1024
-
1025
- if (sshKey === '__custom') {
1026
- const { customSshKey } = await runPrompt([
1027
- {
1028
- type: 'input',
1029
- name: 'customSshKey',
1030
- message: 'SSH key path',
1031
- default: preselectedKey
1032
- }
1033
- ])
1034
-
1035
- sshKey = customSshKey.trim() || preselectedKey
1036
- }
1037
-
1038
- return {
1039
- sshUser: answers.sshUser.trim() || defaultUser,
1040
- sshKey: sshKey.trim() || preselectedKey
1041
- }
1042
- }
1043
-
1044
- async function ensureSshDetails(config, currentDir) {
1045
- if (config.sshUser && config.sshKey) {
1046
- return false
1047
- }
1048
-
1049
- logProcessing('SSH details missing. Please provide them now.')
1050
- const details = await promptSshDetails(currentDir, config)
1051
- Object.assign(config, details)
1052
- return true
1053
- }
1054
-
1055
- function expandHomePath(targetPath) {
1056
- if (!targetPath) {
1057
- return targetPath
1058
- }
1059
-
1060
- if (targetPath.startsWith('~')) {
1061
- return path.join(os.homedir(), targetPath.slice(1))
1062
- }
1063
-
1064
- return targetPath
1065
- }
1066
-
1067
- async function resolveSshKeyPath(targetPath) {
1068
- const expanded = expandHomePath(targetPath)
1069
-
1070
- try {
1071
- await fs.access(expanded)
1072
- } catch (error) {
1073
- throw new Error(`SSH key not accessible at ${expanded}`)
1074
- }
1075
-
1076
- return expanded
1077
- }
1078
-
1079
- function resolveRemotePath(projectPath, remoteHome) {
1080
- if (!projectPath) {
1081
- return projectPath
1082
- }
1083
-
1084
- const sanitizedHome = remoteHome.replace(/\/+$/, '')
1085
-
1086
- if (projectPath === '~') {
1087
- return sanitizedHome
1088
- }
1089
-
1090
- if (projectPath.startsWith('~/')) {
1091
- const remainder = projectPath.slice(2)
1092
- return remainder ? `${sanitizedHome}/${remainder}` : sanitizedHome
1093
- }
1094
-
1095
- if (projectPath.startsWith('/')) {
1096
- return projectPath
1097
- }
1098
-
1099
- return `${sanitizedHome}/${projectPath}`
1100
- }
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
-
1188
- async function isLocalLaravelProject(rootDir) {
1189
- try {
1190
- const artisanPath = path.join(rootDir, 'artisan')
1191
- const composerPath = path.join(rootDir, 'composer.json')
1192
-
1193
- await fs.access(artisanPath)
1194
- const composerContent = await fs.readFile(composerPath, 'utf8')
1195
- const composerJson = JSON.parse(composerContent)
1196
-
1197
- return (
1198
- composerJson.require &&
1199
- typeof composerJson.require === 'object' &&
1200
- 'laravel/framework' in composerJson.require
1201
- )
1202
- } catch {
1203
- return false
1204
- }
1205
- }
1206
-
1207
- async function runRemoteTasks(config, options = {}) {
1208
- const { snapshot = null, rootDir = process.cwd() } = options
1209
-
1210
- await cleanupOldLogs(rootDir)
1211
- await ensureLocalRepositoryState(config.branch, rootDir)
1212
-
1213
- const isLaravel = await isLocalLaravelProject(rootDir)
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
- }
1236
- }
1237
- } else {
1238
- logProcessing('Pre-push git hook detected. Skipping local linting and test execution.')
1239
- }
1240
-
1241
- const ssh = createSshClient()
1242
- const sshUser = config.sshUser || os.userInfo().username
1243
- const privateKeyPath = await resolveSshKeyPath(config.sshKey)
1244
- const privateKey = await fs.readFile(privateKeyPath, 'utf8')
1245
-
1246
- logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
1247
-
1248
- let lockAcquired = false
1249
-
1250
- try {
1251
- await ssh.connect({
1252
- host: config.serverIp,
1253
- username: sshUser,
1254
- privateKey
1255
- })
1256
-
1257
- const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
1258
- const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
1259
- const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
1260
-
1261
- logProcessing(`Connection established. Acquiring deployment lock on server...`)
1262
- await acquireRemoteLock(ssh, remoteCwd, rootDir)
1263
- lockAcquired = true
1264
- logProcessing(`Lock acquired. Running deployment commands in ${remoteCwd}...`)
1265
-
1266
- // Robust environment bootstrap that works even when profile files don't export PATH
1267
- // for non-interactive shells. This handles:
1268
- // 1. Sourcing profile files (may not export PATH for non-interactive shells)
1269
- // 2. Loading nvm if available (common Node.js installation method)
1270
- // 3. Finding and adding common Node.js/npm installation paths
1271
- const profileBootstrap = [
1272
- // Source profile files (may set PATH, but often skip for non-interactive shells)
1273
- 'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile"; fi',
1274
- 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile"; fi',
1275
- 'if [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi',
1276
- 'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile"; fi',
1277
- 'if [ -f "$HOME/.zshrc" ]; then . "$HOME/.zshrc"; fi',
1278
- // Load nvm if available (common Node.js installation method)
1279
- 'if [ -s "$HOME/.nvm/nvm.sh" ]; then . "$HOME/.nvm/nvm.sh"; fi',
1280
- 'if [ -s "$HOME/.config/nvm/nvm.sh" ]; then . "$HOME/.config/nvm/nvm.sh"; fi',
1281
- 'if [ -s "/usr/local/opt/nvm/nvm.sh" ]; then . "/usr/local/opt/nvm/nvm.sh"; fi',
1282
- // Try to find npm/node in common locations and add to PATH
1283
- 'if command -v npm >/dev/null 2>&1; then :',
1284
- 'elif [ -d "$HOME/.nvm/versions/node" ]; then NODE_VERSION=$(ls -1 "$HOME/.nvm/versions/node" | tail -1) && export PATH="$HOME/.nvm/versions/node/$NODE_VERSION/bin:$PATH"',
1285
- 'elif [ -d "/usr/local/lib/node_modules/npm/bin" ]; then export PATH="/usr/local/lib/node_modules/npm/bin:$PATH"',
1286
- 'elif [ -d "/opt/homebrew/bin" ] && [ -f "/opt/homebrew/bin/npm" ]; then export PATH="/opt/homebrew/bin:$PATH"',
1287
- 'elif [ -d "/usr/local/bin" ] && [ -f "/usr/local/bin/npm" ]; then export PATH="/usr/local/bin:$PATH"',
1288
- 'elif [ -d "$HOME/.local/bin" ] && [ -f "$HOME/.local/bin/npm" ]; then export PATH="$HOME/.local/bin:$PATH"',
1289
- 'fi'
1290
- ].join('; ')
1291
-
1292
- const escapeForDoubleQuotes = (value) => value.replace(/(["\\$`])/g, '\\$1')
1293
-
1294
- const executeRemote = async (label, command, options = {}) => {
1295
- const { cwd = remoteCwd, allowFailure = false, printStdout = true, bootstrapEnv = true } = options
1296
- logProcessing(`\n→ ${label}`)
1297
-
1298
- let wrappedCommand = command
1299
- let execOptions = { cwd }
1300
-
1301
- if (bootstrapEnv) {
1302
- const cwdForShell = escapeForDoubleQuotes(cwd)
1303
- wrappedCommand = `${profileBootstrap}; cd "${cwdForShell}" && ${command}`
1304
- execOptions = {}
1305
- }
1306
-
1307
- const result = await ssh.execCommand(wrappedCommand, execOptions)
1308
-
1309
- // Log all output to file
1310
- if (result.stdout && result.stdout.trim()) {
1311
- await writeToLogFile(rootDir, `[${label}] STDOUT:\n${result.stdout.trim()}`)
1312
- }
1313
-
1314
- if (result.stderr && result.stderr.trim()) {
1315
- await writeToLogFile(rootDir, `[${label}] STDERR:\n${result.stderr.trim()}`)
1316
- }
1317
-
1318
- // Only show errors in terminal
1319
- if (result.code !== 0) {
1320
- if (result.stdout && result.stdout.trim()) {
1321
- logError(`\n[${label}] Output:\n${result.stdout.trim()}`)
1322
- }
1323
-
1324
- if (result.stderr && result.stderr.trim()) {
1325
- logError(`\n[${label}] Error:\n${result.stderr.trim()}`)
1326
- }
1327
- }
1328
-
1329
- if (result.code !== 0 && !allowFailure) {
1330
- const stderr = result.stderr?.trim() ?? ''
1331
- if (/command not found/.test(stderr) || /is not recognized/.test(stderr)) {
1332
- throw new Error(
1333
- `Command failed: ${command}. Ensure the remote environment loads required tools for non-interactive shells (e.g. export PATH in profile scripts).`
1334
- )
1335
- }
1336
-
1337
- throw new Error(`Command failed: ${command}`)
1338
- }
1339
-
1340
- // Show success confirmation with command
1341
- if (result.code === 0) {
1342
- logSuccess(`✓ ${command}`)
1343
- }
1344
-
1345
- return result
1346
- }
1347
-
1348
- const laravelCheck = await ssh.execCommand(
1349
- 'if [ -f artisan ] && [ -f composer.json ] && grep -q "laravel/framework" composer.json; then echo "yes"; else echo "no"; fi',
1350
- { cwd: remoteCwd }
1351
- )
1352
- const isLaravel = laravelCheck.stdout.trim() === 'yes'
1353
-
1354
- if (isLaravel) {
1355
- logSuccess('Laravel project detected.')
1356
- } else {
1357
- logWarning('Laravel project not detected; skipping Laravel-specific maintenance tasks.')
1358
- }
1359
-
1360
- let changedFiles = []
1361
-
1362
- if (snapshot && snapshot.changedFiles) {
1363
- changedFiles = snapshot.changedFiles
1364
- logProcessing('Resuming deployment with saved task snapshot.')
1365
- } else if (isLaravel) {
1366
- await executeRemote(`Fetch latest changes for ${config.branch}`, `git fetch origin ${config.branch}`)
1367
-
1368
- const diffResult = await executeRemote(
1369
- 'Inspect pending changes',
1370
- `git diff --name-only HEAD..origin/${config.branch}`,
1371
- { printStdout: false }
1372
- )
1373
-
1374
- changedFiles = diffResult.stdout
1375
- .split(/\r?\n/)
1376
- .map((line) => line.trim())
1377
- .filter(Boolean)
1378
-
1379
- if (changedFiles.length > 0) {
1380
- const preview = changedFiles
1381
- .slice(0, 20)
1382
- .map((file) => ` - ${file}`)
1383
- .join('\n')
1384
-
1385
- logProcessing(
1386
- `Detected ${changedFiles.length} changed file(s):\n${preview}${changedFiles.length > 20 ? '\n - ...' : ''
1387
- }`
1388
- )
1389
- } else {
1390
- logProcessing('No upstream file changes detected.')
1391
- }
1392
- }
1393
-
1394
- const shouldRunComposer =
1395
- isLaravel &&
1396
- changedFiles.some(
1397
- (file) =>
1398
- file === 'composer.json' ||
1399
- file === 'composer.lock' ||
1400
- file.endsWith('/composer.json') ||
1401
- file.endsWith('/composer.lock')
1402
- )
1403
-
1404
- const shouldRunMigrations =
1405
- isLaravel &&
1406
- changedFiles.some(
1407
- (file) => file.startsWith('database/migrations/') && file.endsWith('.php')
1408
- )
1409
-
1410
- const hasPhpChanges = isLaravel && changedFiles.some((file) => file.endsWith('.php'))
1411
-
1412
- const shouldRunNpmInstall =
1413
- isLaravel &&
1414
- changedFiles.some(
1415
- (file) =>
1416
- file === 'package.json' ||
1417
- file === 'package-lock.json' ||
1418
- file.endsWith('/package.json') ||
1419
- file.endsWith('/package-lock.json')
1420
- )
1421
-
1422
- const hasFrontendChanges =
1423
- isLaravel &&
1424
- changedFiles.some((file) =>
1425
- ['.vue', '.css', '.scss', '.js', '.ts', '.tsx', '.less'].some((ext) =>
1426
- file.endsWith(ext)
1427
- )
1428
- )
1429
-
1430
- const shouldRunBuild = isLaravel && (hasFrontendChanges || shouldRunNpmInstall)
1431
- const shouldClearCaches = hasPhpChanges
1432
- const shouldRestartQueues = hasPhpChanges
1433
-
1434
- let horizonConfigured = false
1435
- if (shouldRestartQueues) {
1436
- const horizonCheck = await ssh.execCommand(
1437
- 'if [ -f config/horizon.php ]; then echo "yes"; else echo "no"; fi',
1438
- { cwd: remoteCwd }
1439
- )
1440
- horizonConfigured = horizonCheck.stdout.trim() === 'yes'
1441
- }
1442
-
1443
- const steps = [
1444
- {
1445
- label: `Pull latest changes for ${config.branch}`,
1446
- command: `git pull origin ${config.branch}`
1447
- }
1448
- ]
1449
-
1450
- if (shouldRunComposer) {
1451
- steps.push({
1452
- label: 'Update Composer dependencies',
1453
- command: 'composer update --no-dev --no-interaction --prefer-dist'
1454
- })
1455
- }
1456
-
1457
- if (shouldRunMigrations) {
1458
- steps.push({
1459
- label: 'Run database migrations',
1460
- command: 'php artisan migrate --force'
1461
- })
1462
- }
1463
-
1464
- if (shouldRunNpmInstall) {
1465
- steps.push({
1466
- label: 'Install Node dependencies',
1467
- command: 'npm install'
1468
- })
1469
- }
1470
-
1471
- if (shouldRunBuild) {
1472
- steps.push({
1473
- label: 'Compile frontend assets',
1474
- command: 'npm run build'
1475
- })
1476
- }
1477
-
1478
- if (shouldClearCaches) {
1479
- steps.push({
1480
- label: 'Clear Laravel caches',
1481
- command: 'php artisan cache:clear && php artisan config:clear && php artisan view:clear'
1482
- })
1483
- }
1484
-
1485
- if (shouldRestartQueues) {
1486
- steps.push({
1487
- label: horizonConfigured ? 'Restart Horizon workers' : 'Restart queue workers',
1488
- command: horizonConfigured ? 'php artisan horizon:terminate' : 'php artisan queue:restart'
1489
- })
1490
- }
1491
-
1492
- const usefulSteps = steps.length > 1
1493
-
1494
- let pendingSnapshot
1495
-
1496
- if (usefulSteps) {
1497
- pendingSnapshot = snapshot ?? {
1498
- serverName: config.serverName,
1499
- branch: config.branch,
1500
- projectPath: config.projectPath,
1501
- sshUser: config.sshUser,
1502
- createdAt: new Date().toISOString(),
1503
- changedFiles,
1504
- taskLabels: steps.map((step) => step.label)
1505
- }
1506
-
1507
- await savePendingTasksSnapshot(rootDir, pendingSnapshot)
1508
-
1509
- const payload = Buffer.from(JSON.stringify(pendingSnapshot)).toString('base64')
1510
- await executeRemote(
1511
- 'Record pending deployment tasks',
1512
- `mkdir -p .zephyr && echo '${payload}' | base64 --decode > .zephyr/${PENDING_TASKS_FILE}`,
1513
- { printStdout: false }
1514
- )
1515
- }
1516
-
1517
- if (steps.length === 1) {
1518
- logProcessing('No additional maintenance tasks scheduled beyond git pull.')
1519
- } else {
1520
- const extraTasks = steps
1521
- .slice(1)
1522
- .map((step) => step.label)
1523
- .join(', ')
1524
-
1525
- logProcessing(`Additional tasks scheduled: ${extraTasks}`)
1526
- }
1527
-
1528
- let completed = false
1529
-
1530
- try {
1531
- for (const step of steps) {
1532
- await executeRemote(step.label, step.command)
1533
- }
1534
-
1535
- completed = true
1536
- } finally {
1537
- if (usefulSteps && completed) {
1538
- await executeRemote(
1539
- 'Clear pending deployment snapshot',
1540
- `rm -f .zephyr/${PENDING_TASKS_FILE}`,
1541
- { printStdout: false, allowFailure: true }
1542
- )
1543
- await clearPendingTasksSnapshot(rootDir)
1544
- }
1545
- }
1546
-
1547
- logSuccess('\nDeployment commands completed successfully.')
1548
-
1549
- const logPath = await getLogFilePath(rootDir)
1550
- logSuccess(`\nAll task output has been logged to: ${logPath}`)
1551
- } catch (error) {
1552
- const logPath = logFilePath || await getLogFilePath(rootDir).catch(() => null)
1553
- if (logPath) {
1554
- logError(`\nTask output has been logged to: ${logPath}`)
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
-
1569
- throw new Error(`Deployment failed: ${error.message}`)
1570
- } finally {
1571
- if (lockAcquired && ssh) {
1572
- try {
1573
- const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
1574
- const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
1575
- const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
1576
- await releaseRemoteLock(ssh, remoteCwd)
1577
- await releaseLocalLock(rootDir)
1578
- } catch (error) {
1579
- logWarning(`Failed to release lock: ${error.message}`)
1580
- }
1581
- }
1582
- await closeLogFile()
1583
- if (ssh) {
1584
- ssh.dispose()
1585
- }
1586
- }
1587
- }
1588
-
1589
- async function promptServerDetails(existingServers = []) {
1590
- const defaults = {
1591
- serverName: existingServers.length === 0 ? 'home' : `server-${existingServers.length + 1}`,
1592
- serverIp: '1.1.1.1'
1593
- }
1594
-
1595
- const answers = await runPrompt([
1596
- {
1597
- type: 'input',
1598
- name: 'serverName',
1599
- message: 'Server name',
1600
- default: defaults.serverName
1601
- },
1602
- {
1603
- type: 'input',
1604
- name: 'serverIp',
1605
- message: 'Server IP address',
1606
- default: defaults.serverIp
1607
- }
1608
- ])
1609
-
1610
- return {
1611
- id: generateId(),
1612
- serverName: answers.serverName.trim() || defaults.serverName,
1613
- serverIp: answers.serverIp.trim() || defaults.serverIp
1614
- }
1615
- }
1616
-
1617
- async function selectServer(servers) {
1618
- if (servers.length === 0) {
1619
- logProcessing("No servers configured. Let's create one.")
1620
- const server = await promptServerDetails()
1621
- servers.push(server)
1622
- await saveServers(servers)
1623
- logSuccess('Saved server configuration to ~/.config/zephyr/servers.json')
1624
- return server
1625
- }
1626
-
1627
- const choices = servers.map((server, index) => ({
1628
- name: `${server.serverName} (${server.serverIp})`,
1629
- value: index
1630
- }))
1631
-
1632
- choices.push(new inquirer.Separator(), {
1633
- name: '➕ Register a new server',
1634
- value: 'create'
1635
- })
1636
-
1637
- const { selection } = await runPrompt([
1638
- {
1639
- type: 'list',
1640
- name: 'selection',
1641
- message: 'Select server or register new',
1642
- choices,
1643
- default: 0
1644
- }
1645
- ])
1646
-
1647
- if (selection === 'create') {
1648
- const server = await promptServerDetails(servers)
1649
- servers.push(server)
1650
- await saveServers(servers)
1651
- logSuccess('Appended server configuration to ~/.config/zephyr/servers.json')
1652
- return server
1653
- }
1654
-
1655
- return servers[selection]
1656
- }
1657
-
1658
- async function promptAppDetails(currentDir, existing = {}) {
1659
- const branches = await listGitBranches(currentDir)
1660
- const defaultBranch = existing.branch || (branches.includes('master') ? 'master' : branches[0])
1661
- const defaults = {
1662
- projectPath: existing.projectPath || defaultProjectPath(currentDir),
1663
- branch: defaultBranch
1664
- }
1665
-
1666
- const answers = await runPrompt([
1667
- {
1668
- type: 'input',
1669
- name: 'projectPath',
1670
- message: 'Remote project path',
1671
- default: defaults.projectPath
1672
- },
1673
- {
1674
- type: 'list',
1675
- name: 'branchSelection',
1676
- message: 'Branch to deploy',
1677
- choices: [
1678
- ...branches.map((branch) => ({ name: branch, value: branch })),
1679
- new inquirer.Separator(),
1680
- { name: 'Enter custom branch…', value: '__custom' }
1681
- ],
1682
- default: defaults.branch
1683
- }
1684
- ])
1685
-
1686
- let branch = answers.branchSelection
1687
-
1688
- if (branch === '__custom') {
1689
- const { customBranch } = await runPrompt([
1690
- {
1691
- type: 'input',
1692
- name: 'customBranch',
1693
- message: 'Custom branch name',
1694
- default: defaults.branch
1695
- }
1696
- ])
1697
-
1698
- branch = customBranch.trim() || defaults.branch
1699
- }
1700
-
1701
- const sshDetails = await promptSshDetails(currentDir, existing)
1702
-
1703
- return {
1704
- projectPath: answers.projectPath.trim() || defaults.projectPath,
1705
- branch,
1706
- ...sshDetails
1707
- }
1708
- }
1709
-
1710
- async function selectApp(projectConfig, server, currentDir) {
1711
- const apps = projectConfig.apps ?? []
1712
- const matches = apps
1713
- .map((app, index) => ({ app, index }))
1714
- .filter(({ app }) => app.serverId === server.id || app.serverName === server.serverName)
1715
-
1716
- if (matches.length === 0) {
1717
- if (apps.length > 0) {
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
- }
1724
- }
1725
- logProcessing(`No applications configured for ${server.serverName}. Let's create one.`)
1726
- const appDetails = await promptAppDetails(currentDir)
1727
- const appConfig = {
1728
- id: generateId(),
1729
- serverId: server.id,
1730
- serverName: server.serverName,
1731
- ...appDetails
1732
- }
1733
- projectConfig.apps.push(appConfig)
1734
- await saveProjectConfig(currentDir, projectConfig)
1735
- logSuccess('Saved deployment configuration to .zephyr/config.json')
1736
- return appConfig
1737
- }
1738
-
1739
- const choices = matches.map(({ app, index }, matchIndex) => ({
1740
- name: `${app.projectPath} (${app.branch})`,
1741
- value: matchIndex
1742
- }))
1743
-
1744
- choices.push(new inquirer.Separator(), {
1745
- name: '➕ Configure new application for this server',
1746
- value: 'create'
1747
- })
1748
-
1749
- const { selection } = await runPrompt([
1750
- {
1751
- type: 'list',
1752
- name: 'selection',
1753
- message: `Select application for ${server.serverName}`,
1754
- choices,
1755
- default: 0
1756
- }
1757
- ])
1758
-
1759
- if (selection === 'create') {
1760
- const appDetails = await promptAppDetails(currentDir)
1761
- const appConfig = {
1762
- id: generateId(),
1763
- serverId: server.id,
1764
- serverName: server.serverName,
1765
- ...appDetails
1766
- }
1767
- projectConfig.apps.push(appConfig)
1768
- await saveProjectConfig(currentDir, projectConfig)
1769
- logSuccess('Appended deployment configuration to .zephyr/config.json')
1770
- return appConfig
1771
- }
1772
-
1773
- const chosen = matches[selection].app
1774
- return chosen
1775
- }
1776
-
1777
- async function promptPresetName() {
1778
- const { presetName } = await runPrompt([
1779
- {
1780
- type: 'input',
1781
- name: 'presetName',
1782
- message: 'Enter a name for this preset',
1783
- validate: (value) => (value && value.trim().length > 0 ? true : 'Preset name cannot be empty.')
1784
- }
1785
- ])
1786
-
1787
- return presetName.trim()
1788
- }
1789
-
1790
- function generatePresetKey(serverName, projectPath) {
1791
- return `${serverName}:${projectPath}`
1792
- }
1793
-
1794
- async function selectPreset(projectConfig, servers) {
1795
- const presets = projectConfig.presets ?? []
1796
- const apps = projectConfig.apps ?? []
1797
-
1798
- if (presets.length === 0) {
1799
- return null
1800
- }
1801
-
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
- })
1828
-
1829
- choices.push(new inquirer.Separator(), {
1830
- name: '➕ Create new preset',
1831
- value: 'create'
1832
- })
1833
-
1834
- const { selection } = await runPrompt([
1835
- {
1836
- type: 'list',
1837
- name: 'selection',
1838
- message: 'Select preset or create new',
1839
- choices,
1840
- default: 0
1841
- }
1842
- ])
1843
-
1844
- if (selection === 'create') {
1845
- return 'create' // Return a special marker instead of null
1846
- }
1847
-
1848
- return presets[selection]
1849
- }
1850
-
1851
- async function main() {
1852
- const rootDir = process.cwd()
1853
-
1854
- await ensureGitignoreEntry(rootDir)
1855
- await ensureProjectReleaseScript(rootDir)
1856
-
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
-
1862
- let server = null
1863
- let appConfig = null
1864
- let isCreatingNewPreset = false
1865
-
1866
- const preset = await selectPreset(projectConfig, servers)
1867
-
1868
- if (preset === 'create') {
1869
- // User explicitly chose to create a new preset
1870
- isCreatingNewPreset = true
1871
- server = await selectServer(servers)
1872
- appConfig = await selectApp(projectConfig, server, rootDir)
1873
- } else if (preset) {
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.`)
1929
- server = await selectServer(servers)
1930
- appConfig = await selectApp(projectConfig, server, rootDir)
1931
- }
1932
- } else {
1933
- // No presets exist, go through normal flow
1934
- server = await selectServer(servers)
1935
- appConfig = await selectApp(projectConfig, server, rootDir)
1936
- }
1937
-
1938
- const updated = await ensureSshDetails(appConfig, rootDir)
1939
-
1940
- if (updated) {
1941
- await saveProjectConfig(rootDir, projectConfig)
1942
- logSuccess('Updated .zephyr/config.json with SSH details.')
1943
- }
1944
-
1945
- const deploymentConfig = {
1946
- serverName: server.serverName,
1947
- serverIp: server.serverIp,
1948
- projectPath: appConfig.projectPath,
1949
- branch: appConfig.branch,
1950
- sshUser: appConfig.sshUser,
1951
- sshKey: appConfig.sshKey
1952
- }
1953
-
1954
- logProcessing('\nSelected deployment target:')
1955
- console.log(JSON.stringify(deploymentConfig, null, 2))
1956
-
1957
- if (isCreatingNewPreset || !preset) {
1958
- const { presetName } = await runPrompt([
1959
- {
1960
- type: 'input',
1961
- name: 'presetName',
1962
- message: 'Enter a name for this preset (leave blank to skip)',
1963
- default: isCreatingNewPreset ? '' : undefined
1964
- }
1965
- ])
1966
-
1967
- const trimmedName = presetName?.trim()
1968
-
1969
- if (trimmedName && trimmedName.length > 0) {
1970
- const presets = projectConfig.presets ?? []
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
- }
1995
- }
1996
- }
1997
-
1998
- const existingSnapshot = await loadPendingTasksSnapshot(rootDir)
1999
- let snapshotToUse = null
2000
-
2001
- if (existingSnapshot) {
2002
- const matchesSelection =
2003
- existingSnapshot.serverName === deploymentConfig.serverName &&
2004
- existingSnapshot.branch === deploymentConfig.branch
2005
-
2006
- const messageLines = [
2007
- 'Pending deployment tasks were detected from a previous run.',
2008
- `Server: ${existingSnapshot.serverName}`,
2009
- `Branch: ${existingSnapshot.branch}`
2010
- ]
2011
-
2012
- if (existingSnapshot.taskLabels && existingSnapshot.taskLabels.length > 0) {
2013
- messageLines.push(`Tasks: ${existingSnapshot.taskLabels.join(', ')}`)
2014
- }
2015
-
2016
- const { resumePendingTasks } = await runPrompt([
2017
- {
2018
- type: 'confirm',
2019
- name: 'resumePendingTasks',
2020
- message: `${messageLines.join(' | ')}. Resume using this plan?`,
2021
- default: matchesSelection
2022
- }
2023
- ])
2024
-
2025
- if (resumePendingTasks) {
2026
- snapshotToUse = existingSnapshot
2027
- logProcessing('Resuming deployment using saved task snapshot...')
2028
- } else {
2029
- await clearPendingTasksSnapshot(rootDir)
2030
- logWarning('Discarded pending deployment snapshot.')
2031
- }
2032
- }
2033
-
2034
- await runRemoteTasks(deploymentConfig, { rootDir, snapshot: snapshotToUse })
2035
- }
2036
-
2037
- export {
2038
- ensureGitignoreEntry,
2039
- ensureProjectReleaseScript,
2040
- listSshKeys,
2041
- resolveRemotePath,
2042
- isPrivateKeyFile,
2043
- runRemoteTasks,
2044
- promptServerDetails,
2045
- selectServer,
2046
- promptAppDetails,
2047
- selectApp,
2048
- promptSshDetails,
2049
- ensureSshDetails,
2050
- ensureLocalRepositoryState,
2051
- loadServers,
2052
- loadProjectConfig,
2053
- saveProjectConfig,
2054
- main
2055
- }
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { spawn } from 'node:child_process'
4
+ import os from 'node:os'
5
+ import process from 'node:process'
6
+ import crypto from 'node:crypto'
7
+ import chalk from 'chalk'
8
+ import inquirer from 'inquirer'
9
+ import { NodeSSH } from 'node-ssh'
10
+ import { releaseNode } from './release-node.mjs'
11
+
12
+ const IS_WINDOWS = process.platform === 'win32'
13
+
14
+ const PROJECT_CONFIG_DIR = '.zephyr'
15
+ const PROJECT_CONFIG_FILE = 'config.json'
16
+ const GLOBAL_CONFIG_DIR = path.join(os.homedir(), '.config', 'zephyr')
17
+ const SERVERS_FILE = path.join(GLOBAL_CONFIG_DIR, 'servers.json')
18
+ const PROJECT_LOCK_FILE = 'deploy.lock'
19
+ const PENDING_TASKS_FILE = 'pending-tasks.json'
20
+ const RELEASE_SCRIPT_NAME = 'release'
21
+ const RELEASE_SCRIPT_COMMAND = 'npx @wyxos/zephyr@latest'
22
+
23
+ const logProcessing = (message = '') => console.log(chalk.yellow(message))
24
+ const logSuccess = (message = '') => console.log(chalk.green(message))
25
+ const logWarning = (message = '') => console.warn(chalk.yellow(message))
26
+ const logError = (message = '') => console.error(chalk.red(message))
27
+
28
+ let logFilePath = null
29
+
30
+ async function getLogFilePath(rootDir) {
31
+ if (logFilePath) {
32
+ return logFilePath
33
+ }
34
+
35
+ const configDir = getProjectConfigDir(rootDir)
36
+ await ensureDirectory(configDir)
37
+
38
+ const now = new Date()
39
+ const dateStr = now.toISOString().replace(/:/g, '-').replace(/\..+/, '')
40
+ logFilePath = path.join(configDir, `${dateStr}.log`)
41
+
42
+ return logFilePath
43
+ }
44
+
45
+ async function writeToLogFile(rootDir, message) {
46
+ const logPath = await getLogFilePath(rootDir)
47
+ const timestamp = new Date().toISOString()
48
+ await fs.appendFile(logPath, `${timestamp} - ${message}\n`)
49
+ }
50
+
51
+ async function closeLogFile() {
52
+ logFilePath = null
53
+ }
54
+
55
+ async function cleanupOldLogs(rootDir) {
56
+ const configDir = getProjectConfigDir(rootDir)
57
+
58
+ try {
59
+ const files = await fs.readdir(configDir)
60
+ const logFiles = files
61
+ .filter((file) => file.endsWith('.log'))
62
+ .map((file) => ({
63
+ name: file,
64
+ path: path.join(configDir, file)
65
+ }))
66
+
67
+ if (logFiles.length <= 3) {
68
+ return
69
+ }
70
+
71
+ // Get file stats and sort by modification time (newest first)
72
+ const filesWithStats = await Promise.all(
73
+ logFiles.map(async (file) => {
74
+ const stats = await fs.stat(file.path)
75
+ return {
76
+ ...file,
77
+ mtime: stats.mtime
78
+ }
79
+ })
80
+ )
81
+
82
+ filesWithStats.sort((a, b) => b.mtime - a.mtime)
83
+
84
+ // Keep the 3 newest, delete the rest
85
+ const filesToDelete = filesWithStats.slice(3)
86
+
87
+ for (const file of filesToDelete) {
88
+ try {
89
+ await fs.unlink(file.path)
90
+ } catch (error) {
91
+ // Ignore errors when deleting old logs
92
+ }
93
+ }
94
+ } catch (error) {
95
+ // Ignore errors during log cleanup
96
+ if (error.code !== 'ENOENT') {
97
+ // Only log if it's not a "directory doesn't exist" error
98
+ }
99
+ }
100
+ }
101
+
102
+ const createSshClient = () => {
103
+ if (typeof globalThis !== 'undefined' && globalThis.__zephyrSSHFactory) {
104
+ return globalThis.__zephyrSSHFactory()
105
+ }
106
+
107
+ return new NodeSSH()
108
+ }
109
+
110
+ const runPrompt = async (questions) => {
111
+ if (typeof globalThis !== 'undefined' && globalThis.__zephyrPrompt) {
112
+ return globalThis.__zephyrPrompt(questions)
113
+ }
114
+
115
+ return inquirer.prompt(questions)
116
+ }
117
+
118
+ async function runCommand(command, args, { silent = false, cwd } = {}) {
119
+ return new Promise((resolve, reject) => {
120
+ const spawnOptions = {
121
+ stdio: silent ? 'ignore' : 'inherit',
122
+ cwd
123
+ }
124
+
125
+ // On Windows, use shell for commands that might need PATH resolution (php, composer, etc.)
126
+ // Git commands work fine without shell
127
+ if (IS_WINDOWS && command !== 'git') {
128
+ spawnOptions.shell = true
129
+ }
130
+
131
+ const child = spawn(command, args, spawnOptions)
132
+
133
+ child.on('error', reject)
134
+ child.on('close', (code) => {
135
+ if (code === 0) {
136
+ resolve()
137
+ } else {
138
+ const error = new Error(`${command} exited with code ${code}`)
139
+ error.exitCode = code
140
+ reject(error)
141
+ }
142
+ })
143
+ })
144
+ }
145
+
146
+ async function runCommandCapture(command, args, { cwd } = {}) {
147
+ return new Promise((resolve, reject) => {
148
+ let stdout = ''
149
+ let stderr = ''
150
+
151
+ const spawnOptions = {
152
+ stdio: ['ignore', 'pipe', 'pipe'],
153
+ cwd
154
+ }
155
+
156
+ // On Windows, use shell for commands that might need PATH resolution (php, composer, etc.)
157
+ // Git commands work fine without shell
158
+ if (IS_WINDOWS && command !== 'git') {
159
+ spawnOptions.shell = true
160
+ }
161
+
162
+ const child = spawn(command, args, spawnOptions)
163
+
164
+ child.stdout.on('data', (chunk) => {
165
+ stdout += chunk
166
+ })
167
+
168
+ child.stderr.on('data', (chunk) => {
169
+ stderr += chunk
170
+ })
171
+
172
+ child.on('error', reject)
173
+ child.on('close', (code) => {
174
+ if (code === 0) {
175
+ resolve(stdout)
176
+ } else {
177
+ const error = new Error(`${command} exited with code ${code}: ${stderr.trim()}`)
178
+ error.exitCode = code
179
+ reject(error)
180
+ }
181
+ })
182
+ })
183
+ }
184
+
185
+ async function getCurrentBranch(rootDir) {
186
+ const output = await runCommandCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
187
+ cwd: rootDir
188
+ })
189
+
190
+ return output.trim()
191
+ }
192
+
193
+ async function getGitStatus(rootDir) {
194
+ const output = await runCommandCapture('git', ['status', '--porcelain'], {
195
+ cwd: rootDir
196
+ })
197
+
198
+ return output.trim()
199
+ }
200
+
201
+ function hasStagedChanges(statusOutput) {
202
+ if (!statusOutput || statusOutput.length === 0) {
203
+ return false
204
+ }
205
+
206
+ const lines = statusOutput.split('\n').filter((line) => line.trim().length > 0)
207
+
208
+ return lines.some((line) => {
209
+ const firstChar = line[0]
210
+ // In git status --porcelain format:
211
+ // - First char is space: unstaged changes (e.g., " M file")
212
+ // - First char is '?': untracked files (e.g., "?? file")
213
+ // - First char is letter (M, A, D, etc.): staged changes (e.g., "M file")
214
+ // Only return true for staged changes, not unstaged or untracked
215
+ return firstChar && firstChar !== ' ' && firstChar !== '?'
216
+ })
217
+ }
218
+
219
+ async function getUpstreamRef(rootDir) {
220
+ try {
221
+ const output = await runCommandCapture('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], {
222
+ cwd: rootDir
223
+ })
224
+
225
+ const ref = output.trim()
226
+ return ref.length > 0 ? ref : null
227
+ } catch {
228
+ return null
229
+ }
230
+ }
231
+
232
+ async function ensureCommittedChangesPushed(targetBranch, rootDir) {
233
+ const upstreamRef = await getUpstreamRef(rootDir)
234
+
235
+ if (!upstreamRef) {
236
+ logWarning(`Branch ${targetBranch} does not track a remote upstream; skipping automatic push of committed changes.`)
237
+ return { pushed: false, upstreamRef: null }
238
+ }
239
+
240
+ const [remoteName, ...upstreamParts] = upstreamRef.split('/')
241
+ const upstreamBranch = upstreamParts.join('/')
242
+
243
+ if (!remoteName || !upstreamBranch) {
244
+ logWarning(`Unable to determine remote destination for ${targetBranch}. Skipping automatic push.`)
245
+ return { pushed: false, upstreamRef }
246
+ }
247
+
248
+ try {
249
+ await runCommand('git', ['fetch', remoteName], { cwd: rootDir, silent: true })
250
+ } catch (error) {
251
+ logWarning(`Unable to fetch from ${remoteName} before push: ${error.message}`)
252
+ }
253
+
254
+ let remoteExists = true
255
+
256
+ try {
257
+ await runCommand('git', ['show-ref', '--verify', '--quiet', `refs/remotes/${upstreamRef}`], {
258
+ cwd: rootDir,
259
+ silent: true
260
+ })
261
+ } catch {
262
+ remoteExists = false
263
+ }
264
+
265
+ let aheadCount = 0
266
+ let behindCount = 0
267
+
268
+ if (remoteExists) {
269
+ const aheadOutput = await runCommandCapture('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
270
+ cwd: rootDir
271
+ })
272
+
273
+ aheadCount = parseInt(aheadOutput.trim() || '0', 10)
274
+
275
+ const behindOutput = await runCommandCapture('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
276
+ cwd: rootDir
277
+ })
278
+
279
+ behindCount = parseInt(behindOutput.trim() || '0', 10)
280
+ } else {
281
+ aheadCount = 1
282
+ }
283
+
284
+ if (Number.isFinite(behindCount) && behindCount > 0) {
285
+ throw new Error(
286
+ `Local branch ${targetBranch} is behind ${upstreamRef} by ${behindCount} commit${behindCount === 1 ? '' : 's'}. Pull or rebase before deployment.`
287
+ )
288
+ }
289
+
290
+ if (!Number.isFinite(aheadCount) || aheadCount <= 0) {
291
+ return { pushed: false, upstreamRef }
292
+ }
293
+
294
+ const commitLabel = aheadCount === 1 ? 'commit' : 'commits'
295
+ logProcessing(`Found ${aheadCount} ${commitLabel} not yet pushed to ${upstreamRef}. Pushing before deployment...`)
296
+
297
+ await runCommand('git', ['push', remoteName, `${targetBranch}:${upstreamBranch}`], { cwd: rootDir })
298
+ logSuccess(`Pushed committed changes to ${upstreamRef}.`)
299
+
300
+ return { pushed: true, upstreamRef }
301
+ }
302
+
303
+ async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd()) {
304
+ if (!targetBranch) {
305
+ throw new Error('Deployment branch is not defined in the release configuration.')
306
+ }
307
+
308
+ const currentBranch = await getCurrentBranch(rootDir)
309
+
310
+ if (!currentBranch) {
311
+ throw new Error('Unable to determine the current git branch. Ensure this is a git repository.')
312
+ }
313
+
314
+ const initialStatus = await getGitStatus(rootDir)
315
+ const hasPendingChanges = initialStatus.length > 0
316
+
317
+ const statusReport = await runCommandCapture('git', ['status', '--short', '--branch'], {
318
+ cwd: rootDir
319
+ })
320
+
321
+ const lines = statusReport.split(/\r?\n/)
322
+ const branchLine = lines[0] || ''
323
+ const aheadMatch = branchLine.match(/ahead (\d+)/)
324
+ const behindMatch = branchLine.match(/behind (\d+)/)
325
+ const aheadCount = aheadMatch ? parseInt(aheadMatch[1], 10) : 0
326
+ const behindCount = behindMatch ? parseInt(behindMatch[1], 10) : 0
327
+
328
+ if (aheadCount > 0) {
329
+ logWarning(`Local branch ${currentBranch} is ahead of upstream by ${aheadCount} commit${aheadCount === 1 ? '' : 's'}.`)
330
+ }
331
+
332
+ if (behindCount > 0) {
333
+ logProcessing(`Synchronizing local branch ${currentBranch} with its upstream...`)
334
+ try {
335
+ await runCommand('git', ['pull', '--ff-only'], { cwd: rootDir })
336
+ logSuccess('Local branch fast-forwarded with upstream changes.')
337
+ } catch (error) {
338
+ throw new Error(
339
+ `Unable to fast-forward ${currentBranch} with upstream changes. Resolve conflicts manually, then rerun the deployment.\n${error.message}`
340
+ )
341
+ }
342
+ }
343
+
344
+ if (currentBranch !== targetBranch) {
345
+ if (hasPendingChanges) {
346
+ throw new Error(
347
+ `Local repository has uncommitted changes on ${currentBranch}. Commit or stash them before switching to ${targetBranch}.`
348
+ )
349
+ }
350
+
351
+ logProcessing(`Switching local repository from ${currentBranch} to ${targetBranch}...`)
352
+ await runCommand('git', ['checkout', targetBranch], { cwd: rootDir })
353
+ logSuccess(`Checked out ${targetBranch} locally.`)
354
+ }
355
+
356
+ const statusAfterCheckout = currentBranch === targetBranch ? initialStatus : await getGitStatus(rootDir)
357
+
358
+ if (statusAfterCheckout.length === 0) {
359
+ await ensureCommittedChangesPushed(targetBranch, rootDir)
360
+ logProcessing('Local repository is clean. Proceeding with deployment.')
361
+ return
362
+ }
363
+
364
+ if (!hasStagedChanges(statusAfterCheckout)) {
365
+ await ensureCommittedChangesPushed(targetBranch, rootDir)
366
+ logProcessing('No staged changes detected. Unstaged or untracked files will not affect deployment. Proceeding with deployment.')
367
+ return
368
+ }
369
+
370
+ logWarning(`Staged changes detected on ${targetBranch}. A commit is required before deployment.`)
371
+
372
+ const { commitMessage } = await runPrompt([
373
+ {
374
+ type: 'input',
375
+ name: 'commitMessage',
376
+ message: 'Enter a commit message for pending changes before deployment',
377
+ validate: (value) => (value && value.trim().length > 0 ? true : 'Commit message cannot be empty.')
378
+ }
379
+ ])
380
+
381
+ const message = commitMessage.trim()
382
+
383
+ logProcessing('Committing staged changes before deployment...')
384
+ await runCommand('git', ['commit', '-m', message], { cwd: rootDir })
385
+ await runCommand('git', ['push', 'origin', targetBranch], { cwd: rootDir })
386
+ logSuccess(`Committed and pushed changes to origin/${targetBranch}.`)
387
+
388
+ const finalStatus = await getGitStatus(rootDir)
389
+
390
+ if (finalStatus.length > 0) {
391
+ throw new Error('Local repository still has uncommitted changes after commit. Aborting deployment.')
392
+ }
393
+
394
+ await ensureCommittedChangesPushed(targetBranch, rootDir)
395
+ logProcessing('Local repository is clean after committing pending changes.')
396
+ }
397
+
398
+ async function ensureProjectReleaseScript(rootDir) {
399
+ const packageJsonPath = path.join(rootDir, 'package.json')
400
+
401
+ let raw
402
+ try {
403
+ raw = await fs.readFile(packageJsonPath, 'utf8')
404
+ } catch (error) {
405
+ if (error.code === 'ENOENT') {
406
+ return false
407
+ }
408
+
409
+ throw error
410
+ }
411
+
412
+ let packageJson
413
+ try {
414
+ packageJson = JSON.parse(raw)
415
+ } catch (error) {
416
+ logWarning('Unable to parse package.json; skipping release script injection.')
417
+ return false
418
+ }
419
+
420
+ const currentCommand = packageJson?.scripts?.[RELEASE_SCRIPT_NAME]
421
+
422
+ if (currentCommand && currentCommand.includes('@wyxos/zephyr')) {
423
+ return false
424
+ }
425
+
426
+ const { installReleaseScript } = await runPrompt([
427
+ {
428
+ type: 'confirm',
429
+ name: 'installReleaseScript',
430
+ message: 'Add "release" script to package.json that runs "npx @wyxos/zephyr@latest"?',
431
+ default: true
432
+ }
433
+ ])
434
+
435
+ if (!installReleaseScript) {
436
+ return false
437
+ }
438
+
439
+ if (!packageJson.scripts || typeof packageJson.scripts !== 'object') {
440
+ packageJson.scripts = {}
441
+ }
442
+
443
+ packageJson.scripts[RELEASE_SCRIPT_NAME] = RELEASE_SCRIPT_COMMAND
444
+
445
+ const updatedPayload = `${JSON.stringify(packageJson, null, 2)}\n`
446
+ await fs.writeFile(packageJsonPath, updatedPayload)
447
+ logSuccess('Added release script to package.json.')
448
+
449
+ let isGitRepo = false
450
+
451
+ try {
452
+ await runCommand('git', ['rev-parse', '--is-inside-work-tree'], { cwd: rootDir, silent: true })
453
+ isGitRepo = true
454
+ } catch (error) {
455
+ logWarning('Not a git repository; skipping commit for release script addition.')
456
+ }
457
+
458
+ if (isGitRepo) {
459
+ try {
460
+ await runCommand('git', ['add', 'package.json'], { cwd: rootDir, silent: true })
461
+ await runCommand('git', ['commit', '-m', 'chore: add zephyr release script'], { cwd: rootDir, silent: true })
462
+ logSuccess('Committed package.json release script addition.')
463
+ } catch (error) {
464
+ if (error.exitCode === 1) {
465
+ logWarning('Git commit skipped: nothing to commit or pre-commit hook prevented commit.')
466
+ } else {
467
+ throw error
468
+ }
469
+ }
470
+ }
471
+
472
+ return true
473
+ }
474
+
475
+ function getProjectConfigDir(rootDir) {
476
+ return path.join(rootDir, PROJECT_CONFIG_DIR)
477
+ }
478
+
479
+ function getPendingTasksPath(rootDir) {
480
+ return path.join(getProjectConfigDir(rootDir), PENDING_TASKS_FILE)
481
+ }
482
+
483
+ function getLockFilePath(rootDir) {
484
+ return path.join(getProjectConfigDir(rootDir), PROJECT_LOCK_FILE)
485
+ }
486
+
487
+ function createLockPayload() {
488
+ return {
489
+ user: os.userInfo().username,
490
+ pid: process.pid,
491
+ hostname: os.hostname(),
492
+ startedAt: new Date().toISOString()
493
+ }
494
+ }
495
+
496
+ async function acquireLocalLock(rootDir) {
497
+ const lockPath = getLockFilePath(rootDir)
498
+ const configDir = getProjectConfigDir(rootDir)
499
+ await ensureDirectory(configDir)
500
+
501
+ const payload = createLockPayload()
502
+ const payloadJson = JSON.stringify(payload, null, 2)
503
+ await fs.writeFile(lockPath, payloadJson, 'utf8')
504
+
505
+ return payload
506
+ }
507
+
508
+ async function releaseLocalLock(rootDir) {
509
+ const lockPath = getLockFilePath(rootDir)
510
+ try {
511
+ await fs.unlink(lockPath)
512
+ } catch (error) {
513
+ if (error.code !== 'ENOENT') {
514
+ logWarning(`Failed to remove local lock file: ${error.message}`)
515
+ }
516
+ }
517
+ }
518
+
519
+ async function readLocalLock(rootDir) {
520
+ const lockPath = getLockFilePath(rootDir)
521
+ try {
522
+ const content = await fs.readFile(lockPath, 'utf8')
523
+ return JSON.parse(content)
524
+ } catch (error) {
525
+ if (error.code === 'ENOENT') {
526
+ return null
527
+ }
528
+ throw error
529
+ }
530
+ }
531
+
532
+ async function readRemoteLock(ssh, remoteCwd) {
533
+ const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
534
+ const escapedLockPath = lockPath.replace(/'/g, "'\\''")
535
+ const checkCommand = `mkdir -p .zephyr && if [ -f '${escapedLockPath}' ]; then cat '${escapedLockPath}'; else echo "LOCK_NOT_FOUND"; fi`
536
+
537
+ const checkResult = await ssh.execCommand(checkCommand, { cwd: remoteCwd })
538
+
539
+ if (checkResult.stdout && checkResult.stdout.trim() !== 'LOCK_NOT_FOUND' && checkResult.stdout.trim() !== '') {
540
+ try {
541
+ return JSON.parse(checkResult.stdout.trim())
542
+ } catch (error) {
543
+ return { raw: checkResult.stdout.trim() }
544
+ }
545
+ }
546
+
547
+ return null
548
+ }
549
+
550
+ async function compareLocksAndPrompt(rootDir, ssh, remoteCwd) {
551
+ const localLock = await readLocalLock(rootDir)
552
+ const remoteLock = await readRemoteLock(ssh, remoteCwd)
553
+
554
+ if (!localLock || !remoteLock) {
555
+ return false
556
+ }
557
+
558
+ // Compare lock contents - if they match, it's likely stale
559
+ const localKey = `${localLock.user}@${localLock.hostname}:${localLock.pid}:${localLock.startedAt}`
560
+ const remoteKey = `${remoteLock.user}@${remoteLock.hostname}:${remoteLock.pid}:${remoteLock.startedAt}`
561
+
562
+ if (localKey === remoteKey) {
563
+ const startedBy = remoteLock.user ? `${remoteLock.user}@${remoteLock.hostname ?? 'unknown'}` : 'unknown user'
564
+ const startedAt = remoteLock.startedAt ? ` at ${remoteLock.startedAt}` : ''
565
+ const { shouldRemove } = await runPrompt([
566
+ {
567
+ type: 'confirm',
568
+ name: 'shouldRemove',
569
+ message: `Stale lock detected on server (started by ${startedBy}${startedAt}). This appears to be from a failed deployment. Remove it?`,
570
+ default: true
571
+ }
572
+ ])
573
+
574
+ if (shouldRemove) {
575
+ const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
576
+ const escapedLockPath = lockPath.replace(/'/g, "'\\''")
577
+ const removeCommand = `rm -f '${escapedLockPath}'`
578
+ await ssh.execCommand(removeCommand, { cwd: remoteCwd })
579
+ await releaseLocalLock(rootDir)
580
+ return true
581
+ }
582
+ }
583
+
584
+ return false
585
+ }
586
+
587
+ async function acquireRemoteLock(ssh, remoteCwd, rootDir) {
588
+ const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
589
+ const escapedLockPath = lockPath.replace(/'/g, "'\\''")
590
+ const checkCommand = `mkdir -p .zephyr && if [ -f '${escapedLockPath}' ]; then cat '${escapedLockPath}'; else echo "LOCK_NOT_FOUND"; fi`
591
+
592
+ const checkResult = await ssh.execCommand(checkCommand, { cwd: remoteCwd })
593
+
594
+ if (checkResult.stdout && checkResult.stdout.trim() !== 'LOCK_NOT_FOUND' && checkResult.stdout.trim() !== '') {
595
+ // Check if we have a local lock and compare
596
+ const localLock = await readLocalLock(rootDir)
597
+ if (localLock) {
598
+ const removed = await compareLocksAndPrompt(rootDir, ssh, remoteCwd)
599
+ if (removed) {
600
+ // Lock was removed, continue to create new one
601
+ } else {
602
+ // User chose not to remove, throw error
603
+ let details = {}
604
+ try {
605
+ details = JSON.parse(checkResult.stdout.trim())
606
+ } catch (error) {
607
+ details = { raw: checkResult.stdout.trim() }
608
+ }
609
+
610
+ const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
611
+ const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
612
+ throw new Error(
613
+ `Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/${lockPath} if you are sure it is stale.`
614
+ )
615
+ }
616
+ } else {
617
+ // No local lock, but remote lock exists
618
+ let details = {}
619
+ try {
620
+ details = JSON.parse(checkResult.stdout.trim())
621
+ } catch (error) {
622
+ details = { raw: checkResult.stdout.trim() }
623
+ }
624
+
625
+ const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
626
+ const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
627
+ throw new Error(
628
+ `Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/${lockPath} if you are sure it is stale.`
629
+ )
630
+ }
631
+ }
632
+
633
+ const payload = createLockPayload()
634
+ const payloadJson = JSON.stringify(payload, null, 2)
635
+ const payloadBase64 = Buffer.from(payloadJson).toString('base64')
636
+ const createCommand = `mkdir -p .zephyr && echo '${payloadBase64}' | base64 --decode > '${escapedLockPath}'`
637
+
638
+ const createResult = await ssh.execCommand(createCommand, { cwd: remoteCwd })
639
+
640
+ if (createResult.code !== 0) {
641
+ throw new Error(`Failed to create lock file on server: ${createResult.stderr}`)
642
+ }
643
+
644
+ // Create local lock as well
645
+ await acquireLocalLock(rootDir)
646
+
647
+ return lockPath
648
+ }
649
+
650
+ async function releaseRemoteLock(ssh, remoteCwd) {
651
+ const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
652
+ const escapedLockPath = lockPath.replace(/'/g, "'\\''")
653
+ const removeCommand = `rm -f '${escapedLockPath}'`
654
+
655
+ const result = await ssh.execCommand(removeCommand, { cwd: remoteCwd })
656
+ if (result.code !== 0 && result.code !== 1) {
657
+ logWarning(`Failed to remove lock file: ${result.stderr}`)
658
+ }
659
+ }
660
+
661
+ async function loadPendingTasksSnapshot(rootDir) {
662
+ const snapshotPath = getPendingTasksPath(rootDir)
663
+
664
+ try {
665
+ const raw = await fs.readFile(snapshotPath, 'utf8')
666
+ return JSON.parse(raw)
667
+ } catch (error) {
668
+ if (error.code === 'ENOENT') {
669
+ return null
670
+ }
671
+
672
+ throw error
673
+ }
674
+ }
675
+
676
+ async function savePendingTasksSnapshot(rootDir, snapshot) {
677
+ const configDir = getProjectConfigDir(rootDir)
678
+ await ensureDirectory(configDir)
679
+ const payload = `${JSON.stringify(snapshot, null, 2)}\n`
680
+ await fs.writeFile(getPendingTasksPath(rootDir), payload)
681
+ }
682
+
683
+ async function clearPendingTasksSnapshot(rootDir) {
684
+ try {
685
+ await fs.unlink(getPendingTasksPath(rootDir))
686
+ } catch (error) {
687
+ if (error.code !== 'ENOENT') {
688
+ throw error
689
+ }
690
+ }
691
+ }
692
+
693
+ async function ensureGitignoreEntry(rootDir) {
694
+ const gitignorePath = path.join(rootDir, '.gitignore')
695
+ const targetEntry = `${PROJECT_CONFIG_DIR}/`
696
+ let existingContent = ''
697
+
698
+ try {
699
+ existingContent = await fs.readFile(gitignorePath, 'utf8')
700
+ } catch (error) {
701
+ if (error.code !== 'ENOENT') {
702
+ throw error
703
+ }
704
+ }
705
+
706
+ const hasEntry = existingContent
707
+ .split(/\r?\n/)
708
+ .some((line) => line.trim() === targetEntry)
709
+
710
+ if (hasEntry) {
711
+ return
712
+ }
713
+
714
+ const updatedContent = existingContent
715
+ ? `${existingContent.replace(/\s*$/, '')}\n${targetEntry}\n`
716
+ : `${targetEntry}\n`
717
+
718
+ await fs.writeFile(gitignorePath, updatedContent)
719
+ logSuccess('Added .zephyr/ to .gitignore')
720
+
721
+ let isGitRepo = false
722
+ try {
723
+ await runCommand('git', ['rev-parse', '--is-inside-work-tree'], {
724
+ silent: true,
725
+ cwd: rootDir
726
+ })
727
+ isGitRepo = true
728
+ } catch (error) {
729
+ logWarning('Not a git repository; skipping commit for .gitignore update.')
730
+ }
731
+
732
+ if (!isGitRepo) {
733
+ return
734
+ }
735
+
736
+ try {
737
+ await runCommand('git', ['add', '.gitignore'], { cwd: rootDir })
738
+ await runCommand('git', ['commit', '-m', 'chore: ignore zephyr config'], { cwd: rootDir })
739
+ } catch (error) {
740
+ if (error.exitCode === 1) {
741
+ logWarning('Git commit skipped: nothing to commit or pre-commit hook prevented commit.')
742
+ } else {
743
+ throw error
744
+ }
745
+ }
746
+ }
747
+
748
+ async function ensureDirectory(dirPath) {
749
+ await fs.mkdir(dirPath, { recursive: true })
750
+ }
751
+
752
+ function generateId() {
753
+ return crypto.randomBytes(8).toString('hex')
754
+ }
755
+
756
+ function migrateServers(servers) {
757
+ if (!Array.isArray(servers)) {
758
+ return []
759
+ }
760
+
761
+ let needsMigration = false
762
+ const migrated = servers.map((server) => {
763
+ if (!server.id) {
764
+ needsMigration = true
765
+ return {
766
+ ...server,
767
+ id: generateId()
768
+ }
769
+ }
770
+ return server
771
+ })
772
+
773
+ return { servers: migrated, needsMigration }
774
+ }
775
+
776
+ function migrateApps(apps, servers) {
777
+ if (!Array.isArray(apps)) {
778
+ return { apps: [], needsMigration: false }
779
+ }
780
+
781
+ // Create a map of serverName -> serverId for migration
782
+ const serverNameToId = new Map()
783
+ servers.forEach((server) => {
784
+ if (server.id && server.serverName) {
785
+ serverNameToId.set(server.serverName, server.id)
786
+ }
787
+ })
788
+
789
+ let needsMigration = false
790
+ const migrated = apps.map((app) => {
791
+ const updated = { ...app }
792
+
793
+ if (!app.id) {
794
+ needsMigration = true
795
+ updated.id = generateId()
796
+ }
797
+
798
+ // Migrate serverName to serverId if needed
799
+ if (app.serverName && !app.serverId) {
800
+ const serverId = serverNameToId.get(app.serverName)
801
+ if (serverId) {
802
+ needsMigration = true
803
+ updated.serverId = serverId
804
+ }
805
+ }
806
+
807
+ return updated
808
+ })
809
+
810
+ return { apps: migrated, needsMigration }
811
+ }
812
+
813
+ function migratePresets(presets, apps) {
814
+ if (!Array.isArray(presets)) {
815
+ return { presets: [], needsMigration: false }
816
+ }
817
+
818
+ // Create a map of serverName:projectPath -> appId for migration
819
+ const keyToAppId = new Map()
820
+ apps.forEach((app) => {
821
+ if (app.id && app.serverName && app.projectPath) {
822
+ const key = `${app.serverName}:${app.projectPath}`
823
+ keyToAppId.set(key, app.id)
824
+ }
825
+ })
826
+
827
+ let needsMigration = false
828
+ const migrated = presets.map((preset) => {
829
+ const updated = { ...preset }
830
+
831
+ // Migrate from key-based to appId-based if needed
832
+ if (preset.key && !preset.appId) {
833
+ const appId = keyToAppId.get(preset.key)
834
+ if (appId) {
835
+ needsMigration = true
836
+ updated.appId = appId
837
+ // Keep key for backward compatibility during transition, but it's deprecated
838
+ }
839
+ }
840
+
841
+ return updated
842
+ })
843
+
844
+ return { presets: migrated, needsMigration }
845
+ }
846
+
847
+ async function loadServers() {
848
+ try {
849
+ const raw = await fs.readFile(SERVERS_FILE, 'utf8')
850
+ const data = JSON.parse(raw)
851
+ const servers = Array.isArray(data) ? data : []
852
+
853
+ const { servers: migrated, needsMigration } = migrateServers(servers)
854
+
855
+ if (needsMigration) {
856
+ await saveServers(migrated)
857
+ logSuccess('Migrated servers configuration to use unique IDs.')
858
+ }
859
+
860
+ return migrated
861
+ } catch (error) {
862
+ if (error.code === 'ENOENT') {
863
+ return []
864
+ }
865
+
866
+ logWarning('Failed to read servers.json, starting with an empty list.')
867
+ return []
868
+ }
869
+ }
870
+
871
+ async function saveServers(servers) {
872
+ await ensureDirectory(GLOBAL_CONFIG_DIR)
873
+ const payload = JSON.stringify(servers, null, 2)
874
+ await fs.writeFile(SERVERS_FILE, `${payload}\n`)
875
+ }
876
+
877
+ function getProjectConfigPath(rootDir) {
878
+ return path.join(rootDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE)
879
+ }
880
+
881
+ async function loadProjectConfig(rootDir, servers = []) {
882
+ const configPath = getProjectConfigPath(rootDir)
883
+
884
+ try {
885
+ const raw = await fs.readFile(configPath, 'utf8')
886
+ const data = JSON.parse(raw)
887
+ const apps = Array.isArray(data?.apps) ? data.apps : []
888
+ const presets = Array.isArray(data?.presets) ? data.presets : []
889
+
890
+ // Migrate apps first (needs servers for serverName -> serverId mapping)
891
+ const { apps: migratedApps, needsMigration: appsNeedMigration } = migrateApps(apps, servers)
892
+
893
+ // Migrate presets (needs migrated apps for key -> appId mapping)
894
+ const { presets: migratedPresets, needsMigration: presetsNeedMigration } = migratePresets(presets, migratedApps)
895
+
896
+ if (appsNeedMigration || presetsNeedMigration) {
897
+ await saveProjectConfig(rootDir, {
898
+ apps: migratedApps,
899
+ presets: migratedPresets
900
+ })
901
+ logSuccess('Migrated project configuration to use unique IDs.')
902
+ }
903
+
904
+ return {
905
+ apps: migratedApps,
906
+ presets: migratedPresets
907
+ }
908
+ } catch (error) {
909
+ if (error.code === 'ENOENT') {
910
+ return { apps: [], presets: [] }
911
+ }
912
+
913
+ logWarning('Failed to read .zephyr/config.json, starting with an empty list of apps.')
914
+ return { apps: [], presets: [] }
915
+ }
916
+ }
917
+
918
+ async function saveProjectConfig(rootDir, config) {
919
+ const configDir = path.join(rootDir, PROJECT_CONFIG_DIR)
920
+ await ensureDirectory(configDir)
921
+ const payload = JSON.stringify(
922
+ {
923
+ apps: config.apps ?? [],
924
+ presets: config.presets ?? []
925
+ },
926
+ null,
927
+ 2
928
+ )
929
+ await fs.writeFile(path.join(configDir, PROJECT_CONFIG_FILE), `${payload}\n`)
930
+ }
931
+
932
+ function defaultProjectPath(currentDir) {
933
+ return `~/webapps/${path.basename(currentDir)}`
934
+ }
935
+
936
+ async function listGitBranches(currentDir) {
937
+ try {
938
+ const output = await runCommandCapture(
939
+ 'git',
940
+ ['branch', '--format', '%(refname:short)'],
941
+ { cwd: currentDir }
942
+ )
943
+
944
+ const branches = output
945
+ .split(/\r?\n/)
946
+ .map((line) => line.trim())
947
+ .filter(Boolean)
948
+
949
+ return branches.length ? branches : ['master']
950
+ } catch (error) {
951
+ logWarning('Unable to read git branches; defaulting to master.')
952
+ return ['master']
953
+ }
954
+ }
955
+
956
+ async function listSshKeys() {
957
+ const sshDir = path.join(os.homedir(), '.ssh')
958
+
959
+ try {
960
+ const entries = await fs.readdir(sshDir, { withFileTypes: true })
961
+
962
+ const candidates = entries
963
+ .filter((entry) => entry.isFile())
964
+ .map((entry) => entry.name)
965
+ .filter((name) => {
966
+ if (!name) return false
967
+ if (name.startsWith('.')) return false
968
+ if (name.endsWith('.pub')) return false
969
+ if (name.startsWith('known_hosts')) return false
970
+ if (name === 'config') return false
971
+ return name.trim().length > 0
972
+ })
973
+
974
+ const keys = []
975
+
976
+ for (const name of candidates) {
977
+ const filePath = path.join(sshDir, name)
978
+ if (await isPrivateKeyFile(filePath)) {
979
+ keys.push(name)
980
+ }
981
+ }
982
+
983
+ return {
984
+ sshDir,
985
+ keys
986
+ }
987
+ } catch (error) {
988
+ if (error.code === 'ENOENT') {
989
+ return {
990
+ sshDir,
991
+ keys: []
992
+ }
993
+ }
994
+
995
+ throw error
996
+ }
997
+ }
998
+
999
+ async function isPrivateKeyFile(filePath) {
1000
+ try {
1001
+ const content = await fs.readFile(filePath, 'utf8')
1002
+ return /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(content)
1003
+ } catch (error) {
1004
+ return false
1005
+ }
1006
+ }
1007
+
1008
+ async function promptSshDetails(currentDir, existing = {}) {
1009
+ const { sshDir, keys: sshKeys } = await listSshKeys()
1010
+ const defaultUser = existing.sshUser || os.userInfo().username
1011
+ const fallbackKey = path.join(sshDir, 'id_rsa')
1012
+ const preselectedKey = existing.sshKey || (sshKeys.length ? path.join(sshDir, sshKeys[0]) : fallbackKey)
1013
+
1014
+ const sshKeyPrompt = sshKeys.length
1015
+ ? {
1016
+ type: 'list',
1017
+ name: 'sshKeySelection',
1018
+ message: 'SSH key',
1019
+ choices: [
1020
+ ...sshKeys.map((key) => ({ name: key, value: path.join(sshDir, key) })),
1021
+ new inquirer.Separator(),
1022
+ { name: 'Enter custom SSH key path…', value: '__custom' }
1023
+ ],
1024
+ default: preselectedKey
1025
+ }
1026
+ : {
1027
+ type: 'input',
1028
+ name: 'sshKeySelection',
1029
+ message: 'SSH key path',
1030
+ default: preselectedKey
1031
+ }
1032
+
1033
+ const answers = await runPrompt([
1034
+ {
1035
+ type: 'input',
1036
+ name: 'sshUser',
1037
+ message: 'SSH user',
1038
+ default: defaultUser
1039
+ },
1040
+ sshKeyPrompt
1041
+ ])
1042
+
1043
+ let sshKey = answers.sshKeySelection
1044
+
1045
+ if (sshKey === '__custom') {
1046
+ const { customSshKey } = await runPrompt([
1047
+ {
1048
+ type: 'input',
1049
+ name: 'customSshKey',
1050
+ message: 'SSH key path',
1051
+ default: preselectedKey
1052
+ }
1053
+ ])
1054
+
1055
+ sshKey = customSshKey.trim() || preselectedKey
1056
+ }
1057
+
1058
+ return {
1059
+ sshUser: answers.sshUser.trim() || defaultUser,
1060
+ sshKey: sshKey.trim() || preselectedKey
1061
+ }
1062
+ }
1063
+
1064
+ async function ensureSshDetails(config, currentDir) {
1065
+ if (config.sshUser && config.sshKey) {
1066
+ return false
1067
+ }
1068
+
1069
+ logProcessing('SSH details missing. Please provide them now.')
1070
+ const details = await promptSshDetails(currentDir, config)
1071
+ Object.assign(config, details)
1072
+ return true
1073
+ }
1074
+
1075
+ function expandHomePath(targetPath) {
1076
+ if (!targetPath) {
1077
+ return targetPath
1078
+ }
1079
+
1080
+ if (targetPath.startsWith('~')) {
1081
+ return path.join(os.homedir(), targetPath.slice(1))
1082
+ }
1083
+
1084
+ return targetPath
1085
+ }
1086
+
1087
+ async function resolveSshKeyPath(targetPath) {
1088
+ const expanded = expandHomePath(targetPath)
1089
+
1090
+ try {
1091
+ await fs.access(expanded)
1092
+ } catch (error) {
1093
+ throw new Error(`SSH key not accessible at ${expanded}`)
1094
+ }
1095
+
1096
+ return expanded
1097
+ }
1098
+
1099
+ function resolveRemotePath(projectPath, remoteHome) {
1100
+ if (!projectPath) {
1101
+ return projectPath
1102
+ }
1103
+
1104
+ const sanitizedHome = remoteHome.replace(/\/+$/, '')
1105
+
1106
+ if (projectPath === '~') {
1107
+ return sanitizedHome
1108
+ }
1109
+
1110
+ if (projectPath.startsWith('~/')) {
1111
+ const remainder = projectPath.slice(2)
1112
+ return remainder ? `${sanitizedHome}/${remainder}` : sanitizedHome
1113
+ }
1114
+
1115
+ if (projectPath.startsWith('/')) {
1116
+ return projectPath
1117
+ }
1118
+
1119
+ return `${sanitizedHome}/${projectPath}`
1120
+ }
1121
+
1122
+ async function hasPrePushHook(rootDir) {
1123
+ const hookPaths = [
1124
+ path.join(rootDir, '.git', 'hooks', 'pre-push'),
1125
+ path.join(rootDir, '.husky', 'pre-push'),
1126
+ path.join(rootDir, '.githooks', 'pre-push')
1127
+ ]
1128
+
1129
+ for (const hookPath of hookPaths) {
1130
+ try {
1131
+ await fs.access(hookPath)
1132
+ const stats = await fs.stat(hookPath)
1133
+ if (stats.isFile()) {
1134
+ return true
1135
+ }
1136
+ } catch {
1137
+ // Hook doesn't exist at this path, continue checking
1138
+ }
1139
+ }
1140
+
1141
+ return false
1142
+ }
1143
+
1144
+ async function hasLintScript(rootDir) {
1145
+ try {
1146
+ const packageJsonPath = path.join(rootDir, 'package.json')
1147
+ const raw = await fs.readFile(packageJsonPath, 'utf8')
1148
+ const packageJson = JSON.parse(raw)
1149
+ return packageJson.scripts && typeof packageJson.scripts.lint === 'string'
1150
+ } catch {
1151
+ return false
1152
+ }
1153
+ }
1154
+
1155
+ async function hasLaravelPint(rootDir) {
1156
+ try {
1157
+ const pintPath = path.join(rootDir, 'vendor', 'bin', 'pint')
1158
+ await fs.access(pintPath)
1159
+ const stats = await fs.stat(pintPath)
1160
+ return stats.isFile()
1161
+ } catch {
1162
+ return false
1163
+ }
1164
+ }
1165
+
1166
+ async function runLinting(rootDir) {
1167
+ const hasNpmLint = await hasLintScript(rootDir)
1168
+ const hasPint = await hasLaravelPint(rootDir)
1169
+
1170
+ if (hasNpmLint) {
1171
+ logProcessing('Running npm lint...')
1172
+ await runCommand('npm', ['run', 'lint'], { cwd: rootDir })
1173
+ logSuccess('Linting completed.')
1174
+ return true
1175
+ } else if (hasPint) {
1176
+ logProcessing('Running Laravel Pint...')
1177
+ await runCommand('php', ['vendor/bin/pint'], { cwd: rootDir })
1178
+ logSuccess('Linting completed.')
1179
+ return true
1180
+ }
1181
+
1182
+ return false
1183
+ }
1184
+
1185
+ async function hasUncommittedChanges(rootDir) {
1186
+ const status = await getGitStatus(rootDir)
1187
+ return status.length > 0
1188
+ }
1189
+
1190
+ async function commitLintingChanges(rootDir) {
1191
+ const status = await getGitStatus(rootDir)
1192
+
1193
+ if (!hasStagedChanges(status)) {
1194
+ // Stage only modified tracked files (not untracked files)
1195
+ await runCommand('git', ['add', '-u'], { cwd: rootDir })
1196
+ const newStatus = await getGitStatus(rootDir)
1197
+ if (!hasStagedChanges(newStatus)) {
1198
+ return false
1199
+ }
1200
+ }
1201
+
1202
+ logProcessing('Committing linting changes...')
1203
+ await runCommand('git', ['commit', '-m', 'style: apply linting fixes'], { cwd: rootDir })
1204
+ logSuccess('Linting changes committed.')
1205
+ return true
1206
+ }
1207
+
1208
+ async function isLocalLaravelProject(rootDir) {
1209
+ try {
1210
+ const artisanPath = path.join(rootDir, 'artisan')
1211
+ const composerPath = path.join(rootDir, 'composer.json')
1212
+
1213
+ await fs.access(artisanPath)
1214
+ const composerContent = await fs.readFile(composerPath, 'utf8')
1215
+ const composerJson = JSON.parse(composerContent)
1216
+
1217
+ return (
1218
+ composerJson.require &&
1219
+ typeof composerJson.require === 'object' &&
1220
+ 'laravel/framework' in composerJson.require
1221
+ )
1222
+ } catch {
1223
+ return false
1224
+ }
1225
+ }
1226
+
1227
+ async function runRemoteTasks(config, options = {}) {
1228
+ const { snapshot = null, rootDir = process.cwd() } = options
1229
+
1230
+ await cleanupOldLogs(rootDir)
1231
+ await ensureLocalRepositoryState(config.branch, rootDir)
1232
+
1233
+ const isLaravel = await isLocalLaravelProject(rootDir)
1234
+ const hasHook = await hasPrePushHook(rootDir)
1235
+
1236
+ if (!hasHook) {
1237
+ // Run linting before tests
1238
+ const lintRan = await runLinting(rootDir)
1239
+ if (lintRan) {
1240
+ // Check if linting made changes and commit them
1241
+ const hasChanges = await hasUncommittedChanges(rootDir)
1242
+ if (hasChanges) {
1243
+ await commitLintingChanges(rootDir)
1244
+ }
1245
+ }
1246
+
1247
+ // Run tests for Laravel projects
1248
+ if (isLaravel) {
1249
+ logProcessing('Running Laravel tests locally...')
1250
+ try {
1251
+ await runCommand('php', ['artisan', 'test'], { cwd: rootDir })
1252
+ logSuccess('Local tests passed.')
1253
+ } catch (error) {
1254
+ throw new Error(`Local tests failed. Fix test failures before deploying. ${error.message}`)
1255
+ }
1256
+ }
1257
+ } else {
1258
+ logProcessing('Pre-push git hook detected. Skipping local linting and test execution.')
1259
+ }
1260
+
1261
+ const ssh = createSshClient()
1262
+ const sshUser = config.sshUser || os.userInfo().username
1263
+ const privateKeyPath = await resolveSshKeyPath(config.sshKey)
1264
+ const privateKey = await fs.readFile(privateKeyPath, 'utf8')
1265
+
1266
+ logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
1267
+
1268
+ let lockAcquired = false
1269
+
1270
+ try {
1271
+ await ssh.connect({
1272
+ host: config.serverIp,
1273
+ username: sshUser,
1274
+ privateKey
1275
+ })
1276
+
1277
+ const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
1278
+ const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
1279
+ const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
1280
+
1281
+ logProcessing(`Connection established. Acquiring deployment lock on server...`)
1282
+ await acquireRemoteLock(ssh, remoteCwd, rootDir)
1283
+ lockAcquired = true
1284
+ logProcessing(`Lock acquired. Running deployment commands in ${remoteCwd}...`)
1285
+
1286
+ // Robust environment bootstrap that works even when profile files don't export PATH
1287
+ // for non-interactive shells. This handles:
1288
+ // 1. Sourcing profile files (may not export PATH for non-interactive shells)
1289
+ // 2. Loading nvm if available (common Node.js installation method)
1290
+ // 3. Finding and adding common Node.js/npm installation paths
1291
+ const profileBootstrap = [
1292
+ // Source profile files (may set PATH, but often skip for non-interactive shells)
1293
+ 'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile"; fi',
1294
+ 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile"; fi',
1295
+ 'if [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi',
1296
+ 'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile"; fi',
1297
+ 'if [ -f "$HOME/.zshrc" ]; then . "$HOME/.zshrc"; fi',
1298
+ // Load nvm if available (common Node.js installation method)
1299
+ 'if [ -s "$HOME/.nvm/nvm.sh" ]; then . "$HOME/.nvm/nvm.sh"; fi',
1300
+ 'if [ -s "$HOME/.config/nvm/nvm.sh" ]; then . "$HOME/.config/nvm/nvm.sh"; fi',
1301
+ 'if [ -s "/usr/local/opt/nvm/nvm.sh" ]; then . "/usr/local/opt/nvm/nvm.sh"; fi',
1302
+ // Try to find npm/node in common locations and add to PATH
1303
+ 'if command -v npm >/dev/null 2>&1; then :',
1304
+ 'elif [ -d "$HOME/.nvm/versions/node" ]; then NODE_VERSION=$(ls -1 "$HOME/.nvm/versions/node" | tail -1) && export PATH="$HOME/.nvm/versions/node/$NODE_VERSION/bin:$PATH"',
1305
+ 'elif [ -d "/usr/local/lib/node_modules/npm/bin" ]; then export PATH="/usr/local/lib/node_modules/npm/bin:$PATH"',
1306
+ 'elif [ -d "/opt/homebrew/bin" ] && [ -f "/opt/homebrew/bin/npm" ]; then export PATH="/opt/homebrew/bin:$PATH"',
1307
+ 'elif [ -d "/usr/local/bin" ] && [ -f "/usr/local/bin/npm" ]; then export PATH="/usr/local/bin:$PATH"',
1308
+ 'elif [ -d "$HOME/.local/bin" ] && [ -f "$HOME/.local/bin/npm" ]; then export PATH="$HOME/.local/bin:$PATH"',
1309
+ 'fi'
1310
+ ].join('; ')
1311
+
1312
+ const escapeForDoubleQuotes = (value) => value.replace(/(["\\$`])/g, '\\$1')
1313
+
1314
+ const executeRemote = async (label, command, options = {}) => {
1315
+ const { cwd = remoteCwd, allowFailure = false, printStdout = true, bootstrapEnv = true } = options
1316
+ logProcessing(`\n→ ${label}`)
1317
+
1318
+ let wrappedCommand = command
1319
+ let execOptions = { cwd }
1320
+
1321
+ if (bootstrapEnv) {
1322
+ const cwdForShell = escapeForDoubleQuotes(cwd)
1323
+ wrappedCommand = `${profileBootstrap}; cd "${cwdForShell}" && ${command}`
1324
+ execOptions = {}
1325
+ }
1326
+
1327
+ const result = await ssh.execCommand(wrappedCommand, execOptions)
1328
+
1329
+ // Log all output to file
1330
+ if (result.stdout && result.stdout.trim()) {
1331
+ await writeToLogFile(rootDir, `[${label}] STDOUT:\n${result.stdout.trim()}`)
1332
+ }
1333
+
1334
+ if (result.stderr && result.stderr.trim()) {
1335
+ await writeToLogFile(rootDir, `[${label}] STDERR:\n${result.stderr.trim()}`)
1336
+ }
1337
+
1338
+ // Only show errors in terminal
1339
+ if (result.code !== 0) {
1340
+ if (result.stdout && result.stdout.trim()) {
1341
+ logError(`\n[${label}] Output:\n${result.stdout.trim()}`)
1342
+ }
1343
+
1344
+ if (result.stderr && result.stderr.trim()) {
1345
+ logError(`\n[${label}] Error:\n${result.stderr.trim()}`)
1346
+ }
1347
+ }
1348
+
1349
+ if (result.code !== 0 && !allowFailure) {
1350
+ const stderr = result.stderr?.trim() ?? ''
1351
+ if (/command not found/.test(stderr) || /is not recognized/.test(stderr)) {
1352
+ throw new Error(
1353
+ `Command failed: ${command}. Ensure the remote environment loads required tools for non-interactive shells (e.g. export PATH in profile scripts).`
1354
+ )
1355
+ }
1356
+
1357
+ throw new Error(`Command failed: ${command}`)
1358
+ }
1359
+
1360
+ // Show success confirmation with command
1361
+ if (result.code === 0) {
1362
+ logSuccess(`✓ ${command}`)
1363
+ }
1364
+
1365
+ return result
1366
+ }
1367
+
1368
+ const laravelCheck = await ssh.execCommand(
1369
+ 'if [ -f artisan ] && [ -f composer.json ] && grep -q "laravel/framework" composer.json; then echo "yes"; else echo "no"; fi',
1370
+ { cwd: remoteCwd }
1371
+ )
1372
+ const isLaravel = laravelCheck.stdout.trim() === 'yes'
1373
+
1374
+ if (isLaravel) {
1375
+ logSuccess('Laravel project detected.')
1376
+ } else {
1377
+ logWarning('Laravel project not detected; skipping Laravel-specific maintenance tasks.')
1378
+ }
1379
+
1380
+ let changedFiles = []
1381
+
1382
+ if (snapshot && snapshot.changedFiles) {
1383
+ changedFiles = snapshot.changedFiles
1384
+ logProcessing('Resuming deployment with saved task snapshot.')
1385
+ } else if (isLaravel) {
1386
+ await executeRemote(`Fetch latest changes for ${config.branch}`, `git fetch origin ${config.branch}`)
1387
+
1388
+ const diffResult = await executeRemote(
1389
+ 'Inspect pending changes',
1390
+ `git diff --name-only HEAD..origin/${config.branch}`,
1391
+ { printStdout: false }
1392
+ )
1393
+
1394
+ changedFiles = diffResult.stdout
1395
+ .split(/\r?\n/)
1396
+ .map((line) => line.trim())
1397
+ .filter(Boolean)
1398
+
1399
+ if (changedFiles.length > 0) {
1400
+ const preview = changedFiles
1401
+ .slice(0, 20)
1402
+ .map((file) => ` - ${file}`)
1403
+ .join('\n')
1404
+
1405
+ logProcessing(
1406
+ `Detected ${changedFiles.length} changed file(s):\n${preview}${changedFiles.length > 20 ? '\n - ...' : ''
1407
+ }`
1408
+ )
1409
+ } else {
1410
+ logProcessing('No upstream file changes detected.')
1411
+ }
1412
+ }
1413
+
1414
+ const shouldRunComposer =
1415
+ isLaravel &&
1416
+ changedFiles.some(
1417
+ (file) =>
1418
+ file === 'composer.json' ||
1419
+ file === 'composer.lock' ||
1420
+ file.endsWith('/composer.json') ||
1421
+ file.endsWith('/composer.lock')
1422
+ )
1423
+
1424
+ const shouldRunMigrations =
1425
+ isLaravel &&
1426
+ changedFiles.some(
1427
+ (file) => file.startsWith('database/migrations/') && file.endsWith('.php')
1428
+ )
1429
+
1430
+ const hasPhpChanges = isLaravel && changedFiles.some((file) => file.endsWith('.php'))
1431
+
1432
+ const shouldRunNpmInstall =
1433
+ isLaravel &&
1434
+ changedFiles.some(
1435
+ (file) =>
1436
+ file === 'package.json' ||
1437
+ file === 'package-lock.json' ||
1438
+ file.endsWith('/package.json') ||
1439
+ file.endsWith('/package-lock.json')
1440
+ )
1441
+
1442
+ const hasFrontendChanges =
1443
+ isLaravel &&
1444
+ changedFiles.some((file) =>
1445
+ ['.vue', '.css', '.scss', '.js', '.ts', '.tsx', '.less'].some((ext) =>
1446
+ file.endsWith(ext)
1447
+ )
1448
+ )
1449
+
1450
+ const shouldRunBuild = isLaravel && (hasFrontendChanges || shouldRunNpmInstall)
1451
+ const shouldClearCaches = hasPhpChanges
1452
+ const shouldRestartQueues = hasPhpChanges
1453
+
1454
+ let horizonConfigured = false
1455
+ if (shouldRestartQueues) {
1456
+ const horizonCheck = await ssh.execCommand(
1457
+ 'if [ -f config/horizon.php ]; then echo "yes"; else echo "no"; fi',
1458
+ { cwd: remoteCwd }
1459
+ )
1460
+ horizonConfigured = horizonCheck.stdout.trim() === 'yes'
1461
+ }
1462
+
1463
+ const steps = [
1464
+ {
1465
+ label: `Pull latest changes for ${config.branch}`,
1466
+ command: `git pull origin ${config.branch}`
1467
+ }
1468
+ ]
1469
+
1470
+ if (shouldRunComposer) {
1471
+ steps.push({
1472
+ label: 'Update Composer dependencies',
1473
+ command: 'composer update --no-dev --no-interaction --prefer-dist'
1474
+ })
1475
+ }
1476
+
1477
+ if (shouldRunMigrations) {
1478
+ steps.push({
1479
+ label: 'Run database migrations',
1480
+ command: 'php artisan migrate --force'
1481
+ })
1482
+ }
1483
+
1484
+ if (shouldRunNpmInstall) {
1485
+ steps.push({
1486
+ label: 'Install Node dependencies',
1487
+ command: 'npm install'
1488
+ })
1489
+ }
1490
+
1491
+ if (shouldRunBuild) {
1492
+ steps.push({
1493
+ label: 'Compile frontend assets',
1494
+ command: 'npm run build'
1495
+ })
1496
+ }
1497
+
1498
+ if (shouldClearCaches) {
1499
+ steps.push({
1500
+ label: 'Clear Laravel caches',
1501
+ command: 'php artisan cache:clear && php artisan config:clear && php artisan view:clear'
1502
+ })
1503
+ }
1504
+
1505
+ if (shouldRestartQueues) {
1506
+ steps.push({
1507
+ label: horizonConfigured ? 'Restart Horizon workers' : 'Restart queue workers',
1508
+ command: horizonConfigured ? 'php artisan horizon:terminate' : 'php artisan queue:restart'
1509
+ })
1510
+ }
1511
+
1512
+ const usefulSteps = steps.length > 1
1513
+
1514
+ let pendingSnapshot
1515
+
1516
+ if (usefulSteps) {
1517
+ pendingSnapshot = snapshot ?? {
1518
+ serverName: config.serverName,
1519
+ branch: config.branch,
1520
+ projectPath: config.projectPath,
1521
+ sshUser: config.sshUser,
1522
+ createdAt: new Date().toISOString(),
1523
+ changedFiles,
1524
+ taskLabels: steps.map((step) => step.label)
1525
+ }
1526
+
1527
+ await savePendingTasksSnapshot(rootDir, pendingSnapshot)
1528
+
1529
+ const payload = Buffer.from(JSON.stringify(pendingSnapshot)).toString('base64')
1530
+ await executeRemote(
1531
+ 'Record pending deployment tasks',
1532
+ `mkdir -p .zephyr && echo '${payload}' | base64 --decode > .zephyr/${PENDING_TASKS_FILE}`,
1533
+ { printStdout: false }
1534
+ )
1535
+ }
1536
+
1537
+ if (steps.length === 1) {
1538
+ logProcessing('No additional maintenance tasks scheduled beyond git pull.')
1539
+ } else {
1540
+ const extraTasks = steps
1541
+ .slice(1)
1542
+ .map((step) => step.label)
1543
+ .join(', ')
1544
+
1545
+ logProcessing(`Additional tasks scheduled: ${extraTasks}`)
1546
+ }
1547
+
1548
+ let completed = false
1549
+
1550
+ try {
1551
+ for (const step of steps) {
1552
+ await executeRemote(step.label, step.command)
1553
+ }
1554
+
1555
+ completed = true
1556
+ } finally {
1557
+ if (usefulSteps && completed) {
1558
+ await executeRemote(
1559
+ 'Clear pending deployment snapshot',
1560
+ `rm -f .zephyr/${PENDING_TASKS_FILE}`,
1561
+ { printStdout: false, allowFailure: true }
1562
+ )
1563
+ await clearPendingTasksSnapshot(rootDir)
1564
+ }
1565
+ }
1566
+
1567
+ logSuccess('\nDeployment commands completed successfully.')
1568
+
1569
+ const logPath = await getLogFilePath(rootDir)
1570
+ logSuccess(`\nAll task output has been logged to: ${logPath}`)
1571
+ } catch (error) {
1572
+ const logPath = logFilePath || await getLogFilePath(rootDir).catch(() => null)
1573
+ if (logPath) {
1574
+ logError(`\nTask output has been logged to: ${logPath}`)
1575
+ }
1576
+
1577
+ // If lock was acquired but deployment failed, check for stale locks
1578
+ if (lockAcquired && ssh) {
1579
+ try {
1580
+ const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
1581
+ const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
1582
+ const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
1583
+ await compareLocksAndPrompt(rootDir, ssh, remoteCwd)
1584
+ } catch (lockError) {
1585
+ // Ignore lock comparison errors during error handling
1586
+ }
1587
+ }
1588
+
1589
+ throw new Error(`Deployment failed: ${error.message}`)
1590
+ } finally {
1591
+ if (lockAcquired && ssh) {
1592
+ try {
1593
+ const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
1594
+ const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
1595
+ const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
1596
+ await releaseRemoteLock(ssh, remoteCwd)
1597
+ await releaseLocalLock(rootDir)
1598
+ } catch (error) {
1599
+ logWarning(`Failed to release lock: ${error.message}`)
1600
+ }
1601
+ }
1602
+ await closeLogFile()
1603
+ if (ssh) {
1604
+ ssh.dispose()
1605
+ }
1606
+ }
1607
+ }
1608
+
1609
+ async function promptServerDetails(existingServers = []) {
1610
+ const defaults = {
1611
+ serverName: existingServers.length === 0 ? 'home' : `server-${existingServers.length + 1}`,
1612
+ serverIp: '1.1.1.1'
1613
+ }
1614
+
1615
+ const answers = await runPrompt([
1616
+ {
1617
+ type: 'input',
1618
+ name: 'serverName',
1619
+ message: 'Server name',
1620
+ default: defaults.serverName
1621
+ },
1622
+ {
1623
+ type: 'input',
1624
+ name: 'serverIp',
1625
+ message: 'Server IP address',
1626
+ default: defaults.serverIp
1627
+ }
1628
+ ])
1629
+
1630
+ return {
1631
+ id: generateId(),
1632
+ serverName: answers.serverName.trim() || defaults.serverName,
1633
+ serverIp: answers.serverIp.trim() || defaults.serverIp
1634
+ }
1635
+ }
1636
+
1637
+ async function selectServer(servers) {
1638
+ if (servers.length === 0) {
1639
+ logProcessing("No servers configured. Let's create one.")
1640
+ const server = await promptServerDetails()
1641
+ servers.push(server)
1642
+ await saveServers(servers)
1643
+ logSuccess('Saved server configuration to ~/.config/zephyr/servers.json')
1644
+ return server
1645
+ }
1646
+
1647
+ const choices = servers.map((server, index) => ({
1648
+ name: `${server.serverName} (${server.serverIp})`,
1649
+ value: index
1650
+ }))
1651
+
1652
+ choices.push(new inquirer.Separator(), {
1653
+ name: '➕ Register a new server',
1654
+ value: 'create'
1655
+ })
1656
+
1657
+ const { selection } = await runPrompt([
1658
+ {
1659
+ type: 'list',
1660
+ name: 'selection',
1661
+ message: 'Select server or register new',
1662
+ choices,
1663
+ default: 0
1664
+ }
1665
+ ])
1666
+
1667
+ if (selection === 'create') {
1668
+ const server = await promptServerDetails(servers)
1669
+ servers.push(server)
1670
+ await saveServers(servers)
1671
+ logSuccess('Appended server configuration to ~/.config/zephyr/servers.json')
1672
+ return server
1673
+ }
1674
+
1675
+ return servers[selection]
1676
+ }
1677
+
1678
+ async function promptAppDetails(currentDir, existing = {}) {
1679
+ const branches = await listGitBranches(currentDir)
1680
+ const defaultBranch = existing.branch || (branches.includes('master') ? 'master' : branches[0])
1681
+ const defaults = {
1682
+ projectPath: existing.projectPath || defaultProjectPath(currentDir),
1683
+ branch: defaultBranch
1684
+ }
1685
+
1686
+ const answers = await runPrompt([
1687
+ {
1688
+ type: 'input',
1689
+ name: 'projectPath',
1690
+ message: 'Remote project path',
1691
+ default: defaults.projectPath
1692
+ },
1693
+ {
1694
+ type: 'list',
1695
+ name: 'branchSelection',
1696
+ message: 'Branch to deploy',
1697
+ choices: [
1698
+ ...branches.map((branch) => ({ name: branch, value: branch })),
1699
+ new inquirer.Separator(),
1700
+ { name: 'Enter custom branch…', value: '__custom' }
1701
+ ],
1702
+ default: defaults.branch
1703
+ }
1704
+ ])
1705
+
1706
+ let branch = answers.branchSelection
1707
+
1708
+ if (branch === '__custom') {
1709
+ const { customBranch } = await runPrompt([
1710
+ {
1711
+ type: 'input',
1712
+ name: 'customBranch',
1713
+ message: 'Custom branch name',
1714
+ default: defaults.branch
1715
+ }
1716
+ ])
1717
+
1718
+ branch = customBranch.trim() || defaults.branch
1719
+ }
1720
+
1721
+ const sshDetails = await promptSshDetails(currentDir, existing)
1722
+
1723
+ return {
1724
+ projectPath: answers.projectPath.trim() || defaults.projectPath,
1725
+ branch,
1726
+ ...sshDetails
1727
+ }
1728
+ }
1729
+
1730
+ async function selectApp(projectConfig, server, currentDir) {
1731
+ const apps = projectConfig.apps ?? []
1732
+ const matches = apps
1733
+ .map((app, index) => ({ app, index }))
1734
+ .filter(({ app }) => app.serverId === server.id || app.serverName === server.serverName)
1735
+
1736
+ if (matches.length === 0) {
1737
+ if (apps.length > 0) {
1738
+ const availableServers = [...new Set(apps.map((app) => app.serverName).filter(Boolean))]
1739
+ if (availableServers.length > 0) {
1740
+ logWarning(
1741
+ `No applications configured for server "${server.serverName}". Available servers: ${availableServers.join(', ')}`
1742
+ )
1743
+ }
1744
+ }
1745
+ logProcessing(`No applications configured for ${server.serverName}. Let's create one.`)
1746
+ const appDetails = await promptAppDetails(currentDir)
1747
+ const appConfig = {
1748
+ id: generateId(),
1749
+ serverId: server.id,
1750
+ serverName: server.serverName,
1751
+ ...appDetails
1752
+ }
1753
+ projectConfig.apps.push(appConfig)
1754
+ await saveProjectConfig(currentDir, projectConfig)
1755
+ logSuccess('Saved deployment configuration to .zephyr/config.json')
1756
+ return appConfig
1757
+ }
1758
+
1759
+ const choices = matches.map(({ app, index }, matchIndex) => ({
1760
+ name: `${app.projectPath} (${app.branch})`,
1761
+ value: matchIndex
1762
+ }))
1763
+
1764
+ choices.push(new inquirer.Separator(), {
1765
+ name: '➕ Configure new application for this server',
1766
+ value: 'create'
1767
+ })
1768
+
1769
+ const { selection } = await runPrompt([
1770
+ {
1771
+ type: 'list',
1772
+ name: 'selection',
1773
+ message: `Select application for ${server.serverName}`,
1774
+ choices,
1775
+ default: 0
1776
+ }
1777
+ ])
1778
+
1779
+ if (selection === 'create') {
1780
+ const appDetails = await promptAppDetails(currentDir)
1781
+ const appConfig = {
1782
+ id: generateId(),
1783
+ serverId: server.id,
1784
+ serverName: server.serverName,
1785
+ ...appDetails
1786
+ }
1787
+ projectConfig.apps.push(appConfig)
1788
+ await saveProjectConfig(currentDir, projectConfig)
1789
+ logSuccess('Appended deployment configuration to .zephyr/config.json')
1790
+ return appConfig
1791
+ }
1792
+
1793
+ const chosen = matches[selection].app
1794
+ return chosen
1795
+ }
1796
+
1797
+ async function promptPresetName() {
1798
+ const { presetName } = await runPrompt([
1799
+ {
1800
+ type: 'input',
1801
+ name: 'presetName',
1802
+ message: 'Enter a name for this preset',
1803
+ validate: (value) => (value && value.trim().length > 0 ? true : 'Preset name cannot be empty.')
1804
+ }
1805
+ ])
1806
+
1807
+ return presetName.trim()
1808
+ }
1809
+
1810
+ function generatePresetKey(serverName, projectPath) {
1811
+ return `${serverName}:${projectPath}`
1812
+ }
1813
+
1814
+ async function selectPreset(projectConfig, servers) {
1815
+ const presets = projectConfig.presets ?? []
1816
+ const apps = projectConfig.apps ?? []
1817
+
1818
+ if (presets.length === 0) {
1819
+ return null
1820
+ }
1821
+
1822
+ const choices = presets.map((preset, index) => {
1823
+ let displayName = preset.name
1824
+
1825
+ if (preset.appId) {
1826
+ // New format: look up app by ID
1827
+ const app = apps.find((a) => a.id === preset.appId)
1828
+ if (app) {
1829
+ const server = servers.find((s) => s.id === app.serverId || s.serverName === app.serverName)
1830
+ const serverName = server?.serverName || 'unknown'
1831
+ const branch = preset.branch || app.branch || 'unknown'
1832
+ displayName = `${preset.name} (${serverName} → ${app.projectPath} [${branch}])`
1833
+ }
1834
+ } else if (preset.key) {
1835
+ // Legacy format: parse from key
1836
+ const keyParts = preset.key.split(':')
1837
+ const serverName = keyParts[0]
1838
+ const projectPath = keyParts[1]
1839
+ const branch = preset.branch || (keyParts.length === 3 ? keyParts[2] : 'unknown')
1840
+ displayName = `${preset.name} (${serverName} → ${projectPath} [${branch}])`
1841
+ }
1842
+
1843
+ return {
1844
+ name: displayName,
1845
+ value: index
1846
+ }
1847
+ })
1848
+
1849
+ choices.push(new inquirer.Separator(), {
1850
+ name: '➕ Create new preset',
1851
+ value: 'create'
1852
+ })
1853
+
1854
+ const { selection } = await runPrompt([
1855
+ {
1856
+ type: 'list',
1857
+ name: 'selection',
1858
+ message: 'Select preset or create new',
1859
+ choices,
1860
+ default: 0
1861
+ }
1862
+ ])
1863
+
1864
+ if (selection === 'create') {
1865
+ return 'create' // Return a special marker instead of null
1866
+ }
1867
+
1868
+ return presets[selection]
1869
+ }
1870
+
1871
+ async function main(releaseType = null) {
1872
+ // Handle node/vue package release
1873
+ if (releaseType === 'node' || releaseType === 'vue') {
1874
+ try {
1875
+ await releaseNode()
1876
+ return
1877
+ } catch (error) {
1878
+ logError('\nRelease failed:')
1879
+ logError(error.message)
1880
+ if (error.stack) {
1881
+ console.error(error.stack)
1882
+ }
1883
+ process.exit(1)
1884
+ }
1885
+ }
1886
+
1887
+ // Default: Laravel deployment workflow
1888
+ const rootDir = process.cwd()
1889
+
1890
+ await ensureGitignoreEntry(rootDir)
1891
+ await ensureProjectReleaseScript(rootDir)
1892
+
1893
+ // Load servers first (they may be migrated)
1894
+ const servers = await loadServers()
1895
+ // Load project config with servers for migration
1896
+ const projectConfig = await loadProjectConfig(rootDir, servers)
1897
+
1898
+ let server = null
1899
+ let appConfig = null
1900
+ let isCreatingNewPreset = false
1901
+
1902
+ const preset = await selectPreset(projectConfig, servers)
1903
+
1904
+ if (preset === 'create') {
1905
+ // User explicitly chose to create a new preset
1906
+ isCreatingNewPreset = true
1907
+ server = await selectServer(servers)
1908
+ appConfig = await selectApp(projectConfig, server, rootDir)
1909
+ } else if (preset) {
1910
+ // User selected an existing preset - look up by appId
1911
+ if (preset.appId) {
1912
+ appConfig = projectConfig.apps?.find((a) => a.id === preset.appId)
1913
+
1914
+ if (!appConfig) {
1915
+ logWarning(`Preset references app configuration that no longer exists. Creating new configuration.`)
1916
+ server = await selectServer(servers)
1917
+ appConfig = await selectApp(projectConfig, server, rootDir)
1918
+ } else {
1919
+ server = servers.find((s) => s.id === appConfig.serverId || s.serverName === appConfig.serverName)
1920
+
1921
+ if (!server) {
1922
+ logWarning(`Preset references server that no longer exists. Creating new configuration.`)
1923
+ server = await selectServer(servers)
1924
+ appConfig = await selectApp(projectConfig, server, rootDir)
1925
+ } else if (preset.branch && appConfig.branch !== preset.branch) {
1926
+ // Update branch if preset has a different branch
1927
+ appConfig.branch = preset.branch
1928
+ await saveProjectConfig(rootDir, projectConfig)
1929
+ logSuccess(`Updated branch to ${preset.branch} from preset.`)
1930
+ }
1931
+ }
1932
+ } else if (preset.key) {
1933
+ // Legacy preset format - migrate it
1934
+ const keyParts = preset.key.split(':')
1935
+ const serverName = keyParts[0]
1936
+ const projectPath = keyParts[1]
1937
+ const presetBranch = preset.branch || (keyParts.length === 3 ? keyParts[2] : null)
1938
+
1939
+ server = servers.find((s) => s.serverName === serverName)
1940
+
1941
+ if (!server) {
1942
+ logWarning(`Preset references server "${serverName}" which no longer exists. Creating new configuration.`)
1943
+ server = await selectServer(servers)
1944
+ appConfig = await selectApp(projectConfig, server, rootDir)
1945
+ } else {
1946
+ appConfig = projectConfig.apps?.find(
1947
+ (a) => (a.serverId === server.id || a.serverName === serverName) && a.projectPath === projectPath
1948
+ )
1949
+
1950
+ if (!appConfig) {
1951
+ logWarning(`Preset references app configuration that no longer exists. Creating new configuration.`)
1952
+ appConfig = await selectApp(projectConfig, server, rootDir)
1953
+ } else {
1954
+ // Migrate preset to use appId
1955
+ preset.appId = appConfig.id
1956
+ if (presetBranch && appConfig.branch !== presetBranch) {
1957
+ appConfig.branch = presetBranch
1958
+ }
1959
+ preset.branch = appConfig.branch
1960
+ await saveProjectConfig(rootDir, projectConfig)
1961
+ }
1962
+ }
1963
+ } else {
1964
+ logWarning(`Preset format is invalid. Creating new configuration.`)
1965
+ server = await selectServer(servers)
1966
+ appConfig = await selectApp(projectConfig, server, rootDir)
1967
+ }
1968
+ } else {
1969
+ // No presets exist, go through normal flow
1970
+ server = await selectServer(servers)
1971
+ appConfig = await selectApp(projectConfig, server, rootDir)
1972
+ }
1973
+
1974
+ const updated = await ensureSshDetails(appConfig, rootDir)
1975
+
1976
+ if (updated) {
1977
+ await saveProjectConfig(rootDir, projectConfig)
1978
+ logSuccess('Updated .zephyr/config.json with SSH details.')
1979
+ }
1980
+
1981
+ const deploymentConfig = {
1982
+ serverName: server.serverName,
1983
+ serverIp: server.serverIp,
1984
+ projectPath: appConfig.projectPath,
1985
+ branch: appConfig.branch,
1986
+ sshUser: appConfig.sshUser,
1987
+ sshKey: appConfig.sshKey
1988
+ }
1989
+
1990
+ logProcessing('\nSelected deployment target:')
1991
+ console.log(JSON.stringify(deploymentConfig, null, 2))
1992
+
1993
+ if (isCreatingNewPreset || !preset) {
1994
+ const { presetName } = await runPrompt([
1995
+ {
1996
+ type: 'input',
1997
+ name: 'presetName',
1998
+ message: 'Enter a name for this preset (leave blank to skip)',
1999
+ default: isCreatingNewPreset ? '' : undefined
2000
+ }
2001
+ ])
2002
+
2003
+ const trimmedName = presetName?.trim()
2004
+
2005
+ if (trimmedName && trimmedName.length > 0) {
2006
+ const presets = projectConfig.presets ?? []
2007
+
2008
+ // Find app config to get its ID
2009
+ const appId = appConfig.id
2010
+
2011
+ if (!appId) {
2012
+ logWarning('Cannot save preset: app configuration missing ID.')
2013
+ } else {
2014
+ // Check if preset with this appId already exists
2015
+ const existingIndex = presets.findIndex((p) => p.appId === appId)
2016
+ if (existingIndex >= 0) {
2017
+ presets[existingIndex].name = trimmedName
2018
+ presets[existingIndex].branch = deploymentConfig.branch
2019
+ } else {
2020
+ presets.push({
2021
+ name: trimmedName,
2022
+ appId: appId,
2023
+ branch: deploymentConfig.branch
2024
+ })
2025
+ }
2026
+
2027
+ projectConfig.presets = presets
2028
+ await saveProjectConfig(rootDir, projectConfig)
2029
+ logSuccess(`Saved preset "${trimmedName}" to .zephyr/config.json`)
2030
+ }
2031
+ }
2032
+ }
2033
+
2034
+ const existingSnapshot = await loadPendingTasksSnapshot(rootDir)
2035
+ let snapshotToUse = null
2036
+
2037
+ if (existingSnapshot) {
2038
+ const matchesSelection =
2039
+ existingSnapshot.serverName === deploymentConfig.serverName &&
2040
+ existingSnapshot.branch === deploymentConfig.branch
2041
+
2042
+ const messageLines = [
2043
+ 'Pending deployment tasks were detected from a previous run.',
2044
+ `Server: ${existingSnapshot.serverName}`,
2045
+ `Branch: ${existingSnapshot.branch}`
2046
+ ]
2047
+
2048
+ if (existingSnapshot.taskLabels && existingSnapshot.taskLabels.length > 0) {
2049
+ messageLines.push(`Tasks: ${existingSnapshot.taskLabels.join(', ')}`)
2050
+ }
2051
+
2052
+ const { resumePendingTasks } = await runPrompt([
2053
+ {
2054
+ type: 'confirm',
2055
+ name: 'resumePendingTasks',
2056
+ message: `${messageLines.join(' | ')}. Resume using this plan?`,
2057
+ default: matchesSelection
2058
+ }
2059
+ ])
2060
+
2061
+ if (resumePendingTasks) {
2062
+ snapshotToUse = existingSnapshot
2063
+ logProcessing('Resuming deployment using saved task snapshot...')
2064
+ } else {
2065
+ await clearPendingTasksSnapshot(rootDir)
2066
+ logWarning('Discarded pending deployment snapshot.')
2067
+ }
2068
+ }
2069
+
2070
+ await runRemoteTasks(deploymentConfig, { rootDir, snapshot: snapshotToUse })
2071
+ }
2072
+
2073
+ export {
2074
+ ensureGitignoreEntry,
2075
+ ensureProjectReleaseScript,
2076
+ listSshKeys,
2077
+ resolveRemotePath,
2078
+ isPrivateKeyFile,
2079
+ runRemoteTasks,
2080
+ promptServerDetails,
2081
+ selectServer,
2082
+ promptAppDetails,
2083
+ selectApp,
2084
+ promptSshDetails,
2085
+ ensureSshDetails,
2086
+ ensureLocalRepositoryState,
2087
+ loadServers,
2088
+ loadProjectConfig,
2089
+ saveProjectConfig,
2090
+ main,
2091
+ releaseNode
2092
+ }