@wyxos/zephyr 0.1.11 → 0.1.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +104 -75
  2. package/bin/zephyr.mjs +6 -6
  3. package/package.json +48 -48
  4. package/src/index.mjs +1391 -1368
package/src/index.mjs CHANGED
@@ -1,1368 +1,1391 @@
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 chalk from 'chalk'
6
- import inquirer from 'inquirer'
7
- import { NodeSSH } from 'node-ssh'
8
-
9
- const PROJECT_CONFIG_DIR = '.zephyr'
10
- const PROJECT_CONFIG_FILE = 'config.json'
11
- const GLOBAL_CONFIG_DIR = path.join(os.homedir(), '.config', 'zephyr')
12
- const SERVERS_FILE = path.join(GLOBAL_CONFIG_DIR, 'servers.json')
13
- const PROJECT_LOCK_FILE = 'deploy.lock'
14
- const PENDING_TASKS_FILE = 'pending-tasks.json'
15
- const RELEASE_SCRIPT_NAME = 'release'
16
- const RELEASE_SCRIPT_COMMAND = 'npx @wyxos/zephyr@latest'
17
-
18
- const logProcessing = (message = '') => console.log(chalk.yellow(message))
19
- const logSuccess = (message = '') => console.log(chalk.green(message))
20
- const logWarning = (message = '') => console.warn(chalk.yellow(message))
21
- const logError = (message = '') => console.error(chalk.red(message))
22
-
23
- let logFilePath = null
24
-
25
- async function getLogFilePath(rootDir) {
26
- if (logFilePath) {
27
- return logFilePath
28
- }
29
-
30
- const configDir = getProjectConfigDir(rootDir)
31
- await ensureDirectory(configDir)
32
-
33
- const now = new Date()
34
- const dateStr = now.toISOString().replace(/:/g, '-').replace(/\..+/, '')
35
- logFilePath = path.join(configDir, `${dateStr}.log`)
36
-
37
- return logFilePath
38
- }
39
-
40
- async function writeToLogFile(rootDir, message) {
41
- const logPath = await getLogFilePath(rootDir)
42
- const timestamp = new Date().toISOString()
43
- await fs.appendFile(logPath, `${timestamp} - ${message}\n`)
44
- }
45
-
46
- async function closeLogFile() {
47
- logFilePath = null
48
- }
49
-
50
- const createSshClient = () => {
51
- if (typeof globalThis !== 'undefined' && globalThis.__zephyrSSHFactory) {
52
- return globalThis.__zephyrSSHFactory()
53
- }
54
-
55
- return new NodeSSH()
56
- }
57
-
58
- const runPrompt = async (questions) => {
59
- if (typeof globalThis !== 'undefined' && globalThis.__zephyrPrompt) {
60
- return globalThis.__zephyrPrompt(questions)
61
- }
62
-
63
- return inquirer.prompt(questions)
64
- }
65
-
66
- async function runCommand(command, args, { silent = false, cwd } = {}) {
67
- return new Promise((resolve, reject) => {
68
- const child = spawn(command, args, {
69
- stdio: silent ? 'ignore' : 'inherit',
70
- cwd
71
- })
72
-
73
- child.on('error', reject)
74
- child.on('close', (code) => {
75
- if (code === 0) {
76
- resolve()
77
- } else {
78
- const error = new Error(`${command} exited with code ${code}`)
79
- error.exitCode = code
80
- reject(error)
81
- }
82
- })
83
- })
84
- }
85
-
86
- async function runCommandCapture(command, args, { cwd } = {}) {
87
- return new Promise((resolve, reject) => {
88
- let stdout = ''
89
- let stderr = ''
90
-
91
- const child = spawn(command, args, {
92
- stdio: ['ignore', 'pipe', 'pipe'],
93
- cwd
94
- })
95
-
96
- child.stdout.on('data', (chunk) => {
97
- stdout += chunk
98
- })
99
-
100
- child.stderr.on('data', (chunk) => {
101
- stderr += chunk
102
- })
103
-
104
- child.on('error', reject)
105
- child.on('close', (code) => {
106
- if (code === 0) {
107
- resolve(stdout)
108
- } else {
109
- const error = new Error(`${command} exited with code ${code}: ${stderr.trim()}`)
110
- error.exitCode = code
111
- reject(error)
112
- }
113
- })
114
- })
115
- }
116
-
117
- async function getCurrentBranch(rootDir) {
118
- const output = await runCommandCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
119
- cwd: rootDir
120
- })
121
-
122
- return output.trim()
123
- }
124
-
125
- async function getGitStatus(rootDir) {
126
- const output = await runCommandCapture('git', ['status', '--porcelain'], {
127
- cwd: rootDir
128
- })
129
-
130
- return output.trim()
131
- }
132
-
133
- async function getUpstreamRef(rootDir) {
134
- try {
135
- const output = await runCommandCapture('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], {
136
- cwd: rootDir
137
- })
138
-
139
- const ref = output.trim()
140
- return ref.length > 0 ? ref : null
141
- } catch {
142
- return null
143
- }
144
- }
145
-
146
- async function ensureCommittedChangesPushed(targetBranch, rootDir) {
147
- const upstreamRef = await getUpstreamRef(rootDir)
148
-
149
- if (!upstreamRef) {
150
- logWarning(`Branch ${targetBranch} does not track a remote upstream; skipping automatic push of committed changes.`)
151
- return { pushed: false, upstreamRef: null }
152
- }
153
-
154
- const [remoteName, ...upstreamParts] = upstreamRef.split('/')
155
- const upstreamBranch = upstreamParts.join('/')
156
-
157
- if (!remoteName || !upstreamBranch) {
158
- logWarning(`Unable to determine remote destination for ${targetBranch}. Skipping automatic push.`)
159
- return { pushed: false, upstreamRef }
160
- }
161
-
162
- try {
163
- await runCommand('git', ['fetch', remoteName], { cwd: rootDir, silent: true })
164
- } catch (error) {
165
- logWarning(`Unable to fetch from ${remoteName} before push: ${error.message}`)
166
- }
167
-
168
- let remoteExists = true
169
-
170
- try {
171
- await runCommand('git', ['show-ref', '--verify', '--quiet', `refs/remotes/${upstreamRef}`], {
172
- cwd: rootDir,
173
- silent: true
174
- })
175
- } catch {
176
- remoteExists = false
177
- }
178
-
179
- let aheadCount = 0
180
- let behindCount = 0
181
-
182
- if (remoteExists) {
183
- const aheadOutput = await runCommandCapture('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
184
- cwd: rootDir
185
- })
186
-
187
- aheadCount = parseInt(aheadOutput.trim() || '0', 10)
188
-
189
- const behindOutput = await runCommandCapture('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
190
- cwd: rootDir
191
- })
192
-
193
- behindCount = parseInt(behindOutput.trim() || '0', 10)
194
- } else {
195
- aheadCount = 1
196
- }
197
-
198
- if (Number.isFinite(behindCount) && behindCount > 0) {
199
- throw new Error(
200
- `Local branch ${targetBranch} is behind ${upstreamRef} by ${behindCount} commit${behindCount === 1 ? '' : 's'}. Pull or rebase before deployment.`
201
- )
202
- }
203
-
204
- if (!Number.isFinite(aheadCount) || aheadCount <= 0) {
205
- return { pushed: false, upstreamRef }
206
- }
207
-
208
- const commitLabel = aheadCount === 1 ? 'commit' : 'commits'
209
- logProcessing(`Found ${aheadCount} ${commitLabel} not yet pushed to ${upstreamRef}. Pushing before deployment...`)
210
-
211
- await runCommand('git', ['push', remoteName, `${targetBranch}:${upstreamBranch}`], { cwd: rootDir })
212
- logSuccess(`Pushed committed changes to ${upstreamRef}.`)
213
-
214
- return { pushed: true, upstreamRef }
215
- }
216
-
217
- async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd()) {
218
- if (!targetBranch) {
219
- throw new Error('Deployment branch is not defined in the release configuration.')
220
- }
221
-
222
- const currentBranch = await getCurrentBranch(rootDir)
223
-
224
- if (!currentBranch) {
225
- throw new Error('Unable to determine the current git branch. Ensure this is a git repository.')
226
- }
227
-
228
- const initialStatus = await getGitStatus(rootDir)
229
- const hasPendingChanges = initialStatus.length > 0
230
-
231
- const statusReport = await runCommandCapture('git', ['status', '--short', '--branch'], {
232
- cwd: rootDir
233
- })
234
-
235
- const lines = statusReport.split(/\r?\n/)
236
- const branchLine = lines[0] || ''
237
- const aheadMatch = branchLine.match(/ahead (\d+)/)
238
- const behindMatch = branchLine.match(/behind (\d+)/)
239
- const aheadCount = aheadMatch ? parseInt(aheadMatch[1], 10) : 0
240
- const behindCount = behindMatch ? parseInt(behindMatch[1], 10) : 0
241
-
242
- if (aheadCount > 0) {
243
- logWarning(`Local branch ${currentBranch} is ahead of upstream by ${aheadCount} commit${aheadCount === 1 ? '' : 's'}.`)
244
- }
245
-
246
- if (behindCount > 0) {
247
- logProcessing(`Synchronizing local branch ${currentBranch} with its upstream...`)
248
- try {
249
- await runCommand('git', ['pull', '--ff-only'], { cwd: rootDir })
250
- logSuccess('Local branch fast-forwarded with upstream changes.')
251
- } catch (error) {
252
- throw new Error(
253
- `Unable to fast-forward ${currentBranch} with upstream changes. Resolve conflicts manually, then rerun the deployment.\n${error.message}`
254
- )
255
- }
256
- }
257
-
258
- if (currentBranch !== targetBranch) {
259
- if (hasPendingChanges) {
260
- throw new Error(
261
- `Local repository has uncommitted changes on ${currentBranch}. Commit or stash them before switching to ${targetBranch}.`
262
- )
263
- }
264
-
265
- logProcessing(`Switching local repository from ${currentBranch} to ${targetBranch}...`)
266
- await runCommand('git', ['checkout', targetBranch], { cwd: rootDir })
267
- logSuccess(`Checked out ${targetBranch} locally.`)
268
- }
269
-
270
- const statusAfterCheckout = currentBranch === targetBranch ? initialStatus : await getGitStatus(rootDir)
271
-
272
- if (statusAfterCheckout.length === 0) {
273
- await ensureCommittedChangesPushed(targetBranch, rootDir)
274
- logProcessing('Local repository is clean. Proceeding with deployment.')
275
- return
276
- }
277
-
278
- logWarning(`Uncommitted changes detected on ${targetBranch}. A commit is required before deployment.`)
279
-
280
- const { commitMessage } = await runPrompt([
281
- {
282
- type: 'input',
283
- name: 'commitMessage',
284
- message: 'Enter a commit message for pending changes before deployment',
285
- validate: (value) => (value && value.trim().length > 0 ? true : 'Commit message cannot be empty.')
286
- }
287
- ])
288
-
289
- const message = commitMessage.trim()
290
-
291
- logProcessing('Committing local changes before deployment...')
292
- await runCommand('git', ['add', '-A'], { cwd: rootDir })
293
- await runCommand('git', ['commit', '-m', message], { cwd: rootDir })
294
- await runCommand('git', ['push', 'origin', targetBranch], { cwd: rootDir })
295
- logSuccess(`Committed and pushed changes to origin/${targetBranch}.`)
296
-
297
- const finalStatus = await getGitStatus(rootDir)
298
-
299
- if (finalStatus.length > 0) {
300
- throw new Error('Local repository still has uncommitted changes after commit. Aborting deployment.')
301
- }
302
-
303
- await ensureCommittedChangesPushed(targetBranch, rootDir)
304
- logProcessing('Local repository is clean after committing pending changes.')
305
- }
306
-
307
- async function ensureProjectReleaseScript(rootDir) {
308
- const packageJsonPath = path.join(rootDir, 'package.json')
309
-
310
- let raw
311
- try {
312
- raw = await fs.readFile(packageJsonPath, 'utf8')
313
- } catch (error) {
314
- if (error.code === 'ENOENT') {
315
- return false
316
- }
317
-
318
- throw error
319
- }
320
-
321
- let packageJson
322
- try {
323
- packageJson = JSON.parse(raw)
324
- } catch (error) {
325
- logWarning('Unable to parse package.json; skipping release script injection.')
326
- return false
327
- }
328
-
329
- const currentCommand = packageJson?.scripts?.[RELEASE_SCRIPT_NAME]
330
-
331
- if (currentCommand && currentCommand.includes('@wyxos/zephyr')) {
332
- return false
333
- }
334
-
335
- const { installReleaseScript } = await runPrompt([
336
- {
337
- type: 'confirm',
338
- name: 'installReleaseScript',
339
- message: 'Add "release" script to package.json that runs "npx @wyxos/zephyr@latest"?',
340
- default: true
341
- }
342
- ])
343
-
344
- if (!installReleaseScript) {
345
- return false
346
- }
347
-
348
- if (!packageJson.scripts || typeof packageJson.scripts !== 'object') {
349
- packageJson.scripts = {}
350
- }
351
-
352
- packageJson.scripts[RELEASE_SCRIPT_NAME] = RELEASE_SCRIPT_COMMAND
353
-
354
- const updatedPayload = `${JSON.stringify(packageJson, null, 2)}\n`
355
- await fs.writeFile(packageJsonPath, updatedPayload)
356
- logSuccess('Added release script to package.json.')
357
-
358
- let isGitRepo = false
359
-
360
- try {
361
- await runCommand('git', ['rev-parse', '--is-inside-work-tree'], { cwd: rootDir, silent: true })
362
- isGitRepo = true
363
- } catch (error) {
364
- logWarning('Not a git repository; skipping commit for release script addition.')
365
- }
366
-
367
- if (isGitRepo) {
368
- try {
369
- await runCommand('git', ['add', 'package.json'], { cwd: rootDir, silent: true })
370
- await runCommand('git', ['commit', '-m', 'chore: add zephyr release script'], { cwd: rootDir, silent: true })
371
- logSuccess('Committed package.json release script addition.')
372
- } catch (error) {
373
- if (error.exitCode === 1) {
374
- logWarning('Git commit skipped: nothing to commit or pre-commit hook prevented commit.')
375
- } else {
376
- throw error
377
- }
378
- }
379
- }
380
-
381
- return true
382
- }
383
-
384
- function getProjectConfigDir(rootDir) {
385
- return path.join(rootDir, PROJECT_CONFIG_DIR)
386
- }
387
-
388
- function getPendingTasksPath(rootDir) {
389
- return path.join(getProjectConfigDir(rootDir), PENDING_TASKS_FILE)
390
- }
391
-
392
- function getLockFilePath(rootDir) {
393
- return path.join(getProjectConfigDir(rootDir), PROJECT_LOCK_FILE)
394
- }
395
-
396
- async function acquireProjectLock(rootDir) {
397
- const lockDir = getProjectConfigDir(rootDir)
398
- await ensureDirectory(lockDir)
399
- const lockPath = getLockFilePath(rootDir)
400
-
401
- try {
402
- const existing = await fs.readFile(lockPath, 'utf8')
403
- let details = {}
404
- try {
405
- details = JSON.parse(existing)
406
- } catch (error) {
407
- details = { raw: existing }
408
- }
409
-
410
- const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
411
- const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
412
- throw new Error(
413
- `Another deployment is currently in progress (started by ${startedBy}${startedAt}). Remove ${lockPath} if you are sure it is stale.`
414
- )
415
- } catch (error) {
416
- if (error.code !== 'ENOENT') {
417
- throw error
418
- }
419
- }
420
-
421
- const payload = {
422
- user: os.userInfo().username,
423
- pid: process.pid,
424
- hostname: os.hostname(),
425
- startedAt: new Date().toISOString()
426
- }
427
-
428
- await fs.writeFile(lockPath, `${JSON.stringify(payload, null, 2)}\n`)
429
- return lockPath
430
- }
431
-
432
- async function releaseProjectLock(rootDir) {
433
- const lockPath = getLockFilePath(rootDir)
434
- try {
435
- await fs.unlink(lockPath)
436
- } catch (error) {
437
- if (error.code !== 'ENOENT') {
438
- throw error
439
- }
440
- }
441
- }
442
-
443
- async function loadPendingTasksSnapshot(rootDir) {
444
- const snapshotPath = getPendingTasksPath(rootDir)
445
-
446
- try {
447
- const raw = await fs.readFile(snapshotPath, 'utf8')
448
- return JSON.parse(raw)
449
- } catch (error) {
450
- if (error.code === 'ENOENT') {
451
- return null
452
- }
453
-
454
- throw error
455
- }
456
- }
457
-
458
- async function savePendingTasksSnapshot(rootDir, snapshot) {
459
- const configDir = getProjectConfigDir(rootDir)
460
- await ensureDirectory(configDir)
461
- const payload = `${JSON.stringify(snapshot, null, 2)}\n`
462
- await fs.writeFile(getPendingTasksPath(rootDir), payload)
463
- }
464
-
465
- async function clearPendingTasksSnapshot(rootDir) {
466
- try {
467
- await fs.unlink(getPendingTasksPath(rootDir))
468
- } catch (error) {
469
- if (error.code !== 'ENOENT') {
470
- throw error
471
- }
472
- }
473
- }
474
-
475
- async function ensureGitignoreEntry(rootDir) {
476
- const gitignorePath = path.join(rootDir, '.gitignore')
477
- const targetEntry = `${PROJECT_CONFIG_DIR}/`
478
- let existingContent = ''
479
-
480
- try {
481
- existingContent = await fs.readFile(gitignorePath, 'utf8')
482
- } catch (error) {
483
- if (error.code !== 'ENOENT') {
484
- throw error
485
- }
486
- }
487
-
488
- const hasEntry = existingContent
489
- .split(/\r?\n/)
490
- .some((line) => line.trim() === targetEntry)
491
-
492
- if (hasEntry) {
493
- return
494
- }
495
-
496
- const updatedContent = existingContent
497
- ? `${existingContent.replace(/\s*$/, '')}\n${targetEntry}\n`
498
- : `${targetEntry}\n`
499
-
500
- await fs.writeFile(gitignorePath, updatedContent)
501
- logSuccess('Added .zephyr/ to .gitignore')
502
-
503
- let isGitRepo = false
504
- try {
505
- await runCommand('git', ['rev-parse', '--is-inside-work-tree'], {
506
- silent: true,
507
- cwd: rootDir
508
- })
509
- isGitRepo = true
510
- } catch (error) {
511
- logWarning('Not a git repository; skipping commit for .gitignore update.')
512
- }
513
-
514
- if (!isGitRepo) {
515
- return
516
- }
517
-
518
- try {
519
- await runCommand('git', ['add', '.gitignore'], { cwd: rootDir })
520
- await runCommand('git', ['commit', '-m', 'chore: ignore zephyr config'], { cwd: rootDir })
521
- } catch (error) {
522
- if (error.exitCode === 1) {
523
- logWarning('Git commit skipped: nothing to commit or pre-commit hook prevented commit.')
524
- } else {
525
- throw error
526
- }
527
- }
528
- }
529
-
530
- async function ensureDirectory(dirPath) {
531
- await fs.mkdir(dirPath, { recursive: true })
532
- }
533
-
534
- async function loadServers() {
535
- try {
536
- const raw = await fs.readFile(SERVERS_FILE, 'utf8')
537
- const data = JSON.parse(raw)
538
- return Array.isArray(data) ? data : []
539
- } catch (error) {
540
- if (error.code === 'ENOENT') {
541
- return []
542
- }
543
-
544
- logWarning('Failed to read servers.json, starting with an empty list.')
545
- return []
546
- }
547
- }
548
-
549
- async function saveServers(servers) {
550
- await ensureDirectory(GLOBAL_CONFIG_DIR)
551
- const payload = JSON.stringify(servers, null, 2)
552
- await fs.writeFile(SERVERS_FILE, `${payload}\n`)
553
- }
554
-
555
- function getProjectConfigPath(rootDir) {
556
- return path.join(rootDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE)
557
- }
558
-
559
- async function loadProjectConfig(rootDir) {
560
- const configPath = getProjectConfigPath(rootDir)
561
-
562
- try {
563
- const raw = await fs.readFile(configPath, 'utf8')
564
- const data = JSON.parse(raw)
565
- return {
566
- apps: Array.isArray(data?.apps) ? data.apps : []
567
- }
568
- } catch (error) {
569
- if (error.code === 'ENOENT') {
570
- return { apps: [] }
571
- }
572
-
573
- logWarning('Failed to read .zephyr/config.json, starting with an empty list of apps.')
574
- return { apps: [] }
575
- }
576
- }
577
-
578
- async function saveProjectConfig(rootDir, config) {
579
- const configDir = path.join(rootDir, PROJECT_CONFIG_DIR)
580
- await ensureDirectory(configDir)
581
- const payload = JSON.stringify({ apps: config.apps ?? [] }, null, 2)
582
- await fs.writeFile(path.join(configDir, PROJECT_CONFIG_FILE), `${payload}\n`)
583
- }
584
-
585
- function defaultProjectPath(currentDir) {
586
- return `~/webapps/${path.basename(currentDir)}`
587
- }
588
-
589
- async function listGitBranches(currentDir) {
590
- try {
591
- const output = await runCommandCapture(
592
- 'git',
593
- ['branch', '--format', '%(refname:short)'],
594
- { cwd: currentDir }
595
- )
596
-
597
- const branches = output
598
- .split(/\r?\n/)
599
- .map((line) => line.trim())
600
- .filter(Boolean)
601
-
602
- return branches.length ? branches : ['master']
603
- } catch (error) {
604
- logWarning('Unable to read git branches; defaulting to master.')
605
- return ['master']
606
- }
607
- }
608
-
609
- async function listSshKeys() {
610
- const sshDir = path.join(os.homedir(), '.ssh')
611
-
612
- try {
613
- const entries = await fs.readdir(sshDir, { withFileTypes: true })
614
-
615
- const candidates = entries
616
- .filter((entry) => entry.isFile())
617
- .map((entry) => entry.name)
618
- .filter((name) => {
619
- if (!name) return false
620
- if (name.startsWith('.')) return false
621
- if (name.endsWith('.pub')) return false
622
- if (name.startsWith('known_hosts')) return false
623
- if (name === 'config') return false
624
- return name.trim().length > 0
625
- })
626
-
627
- const keys = []
628
-
629
- for (const name of candidates) {
630
- const filePath = path.join(sshDir, name)
631
- if (await isPrivateKeyFile(filePath)) {
632
- keys.push(name)
633
- }
634
- }
635
-
636
- return {
637
- sshDir,
638
- keys
639
- }
640
- } catch (error) {
641
- if (error.code === 'ENOENT') {
642
- return {
643
- sshDir,
644
- keys: []
645
- }
646
- }
647
-
648
- throw error
649
- }
650
- }
651
-
652
- async function isPrivateKeyFile(filePath) {
653
- try {
654
- const content = await fs.readFile(filePath, 'utf8')
655
- return /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(content)
656
- } catch (error) {
657
- return false
658
- }
659
- }
660
-
661
- async function promptSshDetails(currentDir, existing = {}) {
662
- const { sshDir, keys: sshKeys } = await listSshKeys()
663
- const defaultUser = existing.sshUser || os.userInfo().username
664
- const fallbackKey = path.join(sshDir, 'id_rsa')
665
- const preselectedKey = existing.sshKey || (sshKeys.length ? path.join(sshDir, sshKeys[0]) : fallbackKey)
666
-
667
- const sshKeyPrompt = sshKeys.length
668
- ? {
669
- type: 'list',
670
- name: 'sshKeySelection',
671
- message: 'SSH key',
672
- choices: [
673
- ...sshKeys.map((key) => ({ name: key, value: path.join(sshDir, key) })),
674
- new inquirer.Separator(),
675
- { name: 'Enter custom SSH key path…', value: '__custom' }
676
- ],
677
- default: preselectedKey
678
- }
679
- : {
680
- type: 'input',
681
- name: 'sshKeySelection',
682
- message: 'SSH key path',
683
- default: preselectedKey
684
- }
685
-
686
- const answers = await runPrompt([
687
- {
688
- type: 'input',
689
- name: 'sshUser',
690
- message: 'SSH user',
691
- default: defaultUser
692
- },
693
- sshKeyPrompt
694
- ])
695
-
696
- let sshKey = answers.sshKeySelection
697
-
698
- if (sshKey === '__custom') {
699
- const { customSshKey } = await runPrompt([
700
- {
701
- type: 'input',
702
- name: 'customSshKey',
703
- message: 'SSH key path',
704
- default: preselectedKey
705
- }
706
- ])
707
-
708
- sshKey = customSshKey.trim() || preselectedKey
709
- }
710
-
711
- return {
712
- sshUser: answers.sshUser.trim() || defaultUser,
713
- sshKey: sshKey.trim() || preselectedKey
714
- }
715
- }
716
-
717
- async function ensureSshDetails(config, currentDir) {
718
- if (config.sshUser && config.sshKey) {
719
- return false
720
- }
721
-
722
- logProcessing('SSH details missing. Please provide them now.')
723
- const details = await promptSshDetails(currentDir, config)
724
- Object.assign(config, details)
725
- return true
726
- }
727
-
728
- function expandHomePath(targetPath) {
729
- if (!targetPath) {
730
- return targetPath
731
- }
732
-
733
- if (targetPath.startsWith('~')) {
734
- return path.join(os.homedir(), targetPath.slice(1))
735
- }
736
-
737
- return targetPath
738
- }
739
-
740
- async function resolveSshKeyPath(targetPath) {
741
- const expanded = expandHomePath(targetPath)
742
-
743
- try {
744
- await fs.access(expanded)
745
- } catch (error) {
746
- throw new Error(`SSH key not accessible at ${expanded}`)
747
- }
748
-
749
- return expanded
750
- }
751
-
752
- function resolveRemotePath(projectPath, remoteHome) {
753
- if (!projectPath) {
754
- return projectPath
755
- }
756
-
757
- const sanitizedHome = remoteHome.replace(/\/+$/, '')
758
-
759
- if (projectPath === '~') {
760
- return sanitizedHome
761
- }
762
-
763
- if (projectPath.startsWith('~/')) {
764
- const remainder = projectPath.slice(2)
765
- return remainder ? `${sanitizedHome}/${remainder}` : sanitizedHome
766
- }
767
-
768
- if (projectPath.startsWith('/')) {
769
- return projectPath
770
- }
771
-
772
- return `${sanitizedHome}/${projectPath}`
773
- }
774
-
775
- async function runRemoteTasks(config, options = {}) {
776
- const { snapshot = null, rootDir = process.cwd() } = options
777
-
778
- await ensureLocalRepositoryState(config.branch, rootDir)
779
-
780
- const ssh = createSshClient()
781
- const sshUser = config.sshUser || os.userInfo().username
782
- const privateKeyPath = await resolveSshKeyPath(config.sshKey)
783
- const privateKey = await fs.readFile(privateKeyPath, 'utf8')
784
-
785
- logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
786
-
787
- try {
788
- await ssh.connect({
789
- host: config.serverIp,
790
- username: sshUser,
791
- privateKey
792
- })
793
-
794
- const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
795
- const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
796
- const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
797
-
798
- logProcessing(`Connection established. Running deployment commands in ${remoteCwd}...`)
799
-
800
- // Robust environment bootstrap that works even when profile files don't export PATH
801
- // for non-interactive shells. This handles:
802
- // 1. Sourcing profile files (may not export PATH for non-interactive shells)
803
- // 2. Loading nvm if available (common Node.js installation method)
804
- // 3. Finding and adding common Node.js/npm installation paths
805
- const profileBootstrap = [
806
- // Source profile files (may set PATH, but often skip for non-interactive shells)
807
- 'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile"; fi',
808
- 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile"; fi',
809
- 'if [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi',
810
- 'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile"; fi',
811
- 'if [ -f "$HOME/.zshrc" ]; then . "$HOME/.zshrc"; fi',
812
- // Load nvm if available (common Node.js installation method)
813
- 'if [ -s "$HOME/.nvm/nvm.sh" ]; then . "$HOME/.nvm/nvm.sh"; fi',
814
- 'if [ -s "$HOME/.config/nvm/nvm.sh" ]; then . "$HOME/.config/nvm/nvm.sh"; fi',
815
- 'if [ -s "/usr/local/opt/nvm/nvm.sh" ]; then . "/usr/local/opt/nvm/nvm.sh"; fi',
816
- // Try to find npm/node in common locations and add to PATH
817
- 'if command -v npm >/dev/null 2>&1; then :',
818
- '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"',
819
- 'elif [ -d "/usr/local/lib/node_modules/npm/bin" ]; then export PATH="/usr/local/lib/node_modules/npm/bin:$PATH"',
820
- 'elif [ -d "/opt/homebrew/bin" ] && [ -f "/opt/homebrew/bin/npm" ]; then export PATH="/opt/homebrew/bin:$PATH"',
821
- 'elif [ -d "/usr/local/bin" ] && [ -f "/usr/local/bin/npm" ]; then export PATH="/usr/local/bin:$PATH"',
822
- 'elif [ -d "$HOME/.local/bin" ] && [ -f "$HOME/.local/bin/npm" ]; then export PATH="$HOME/.local/bin:$PATH"',
823
- 'fi'
824
- ].join('; ')
825
-
826
- const escapeForDoubleQuotes = (value) => value.replace(/(["\\$`])/g, '\\$1')
827
-
828
- const executeRemote = async (label, command, options = {}) => {
829
- const { cwd = remoteCwd, allowFailure = false, printStdout = true, bootstrapEnv = true } = options
830
- logProcessing(`\n→ ${label}`)
831
-
832
- let wrappedCommand = command
833
- let execOptions = { cwd }
834
-
835
- if (bootstrapEnv) {
836
- const cwdForShell = escapeForDoubleQuotes(cwd)
837
- wrappedCommand = `${profileBootstrap}; cd "${cwdForShell}" && ${command}`
838
- execOptions = {}
839
- }
840
-
841
- const result = await ssh.execCommand(wrappedCommand, execOptions)
842
-
843
- // Log all output to file
844
- if (result.stdout && result.stdout.trim()) {
845
- await writeToLogFile(rootDir, `[${label}] STDOUT:\n${result.stdout.trim()}`)
846
- }
847
-
848
- if (result.stderr && result.stderr.trim()) {
849
- await writeToLogFile(rootDir, `[${label}] STDERR:\n${result.stderr.trim()}`)
850
- }
851
-
852
- // Only show errors in terminal
853
- if (result.code !== 0) {
854
- if (result.stdout && result.stdout.trim()) {
855
- logError(`\n[${label}] Output:\n${result.stdout.trim()}`)
856
- }
857
-
858
- if (result.stderr && result.stderr.trim()) {
859
- logError(`\n[${label}] Error:\n${result.stderr.trim()}`)
860
- }
861
- }
862
-
863
- if (result.code !== 0 && !allowFailure) {
864
- const stderr = result.stderr?.trim() ?? ''
865
- if (/command not found/.test(stderr) || /is not recognized/.test(stderr)) {
866
- throw new Error(
867
- `Command failed: ${command}. Ensure the remote environment loads required tools for non-interactive shells (e.g. export PATH in profile scripts).`
868
- )
869
- }
870
-
871
- throw new Error(`Command failed: ${command}`)
872
- }
873
-
874
- // Show success confirmation with command
875
- if (result.code === 0) {
876
- logSuccess(`✓ ${command}`)
877
- }
878
-
879
- return result
880
- }
881
-
882
- const laravelCheck = await ssh.execCommand(
883
- 'if [ -f artisan ] && [ -f composer.json ] && grep -q "laravel/framework" composer.json; then echo "yes"; else echo "no"; fi',
884
- { cwd: remoteCwd }
885
- )
886
- const isLaravel = laravelCheck.stdout.trim() === 'yes'
887
-
888
- if (isLaravel) {
889
- logSuccess('Laravel project detected.')
890
- } else {
891
- logWarning('Laravel project not detected; skipping Laravel-specific maintenance tasks.')
892
- }
893
-
894
- let changedFiles = []
895
-
896
- if (snapshot && snapshot.changedFiles) {
897
- changedFiles = snapshot.changedFiles
898
- logProcessing('Resuming deployment with saved task snapshot.')
899
- } else if (isLaravel) {
900
- await executeRemote(`Fetch latest changes for ${config.branch}`, `git fetch origin ${config.branch}`)
901
-
902
- const diffResult = await executeRemote(
903
- 'Inspect pending changes',
904
- `git diff --name-only HEAD..origin/${config.branch}`,
905
- { printStdout: false }
906
- )
907
-
908
- changedFiles = diffResult.stdout
909
- .split(/\r?\n/)
910
- .map((line) => line.trim())
911
- .filter(Boolean)
912
-
913
- if (changedFiles.length > 0) {
914
- const preview = changedFiles
915
- .slice(0, 20)
916
- .map((file) => ` - ${file}`)
917
- .join('\n')
918
-
919
- logProcessing(
920
- `Detected ${changedFiles.length} changed file(s):\n${preview}${changedFiles.length > 20 ? '\n - ...' : ''
921
- }`
922
- )
923
- } else {
924
- logProcessing('No upstream file changes detected.')
925
- }
926
- }
927
-
928
- const shouldRunComposer =
929
- isLaravel &&
930
- changedFiles.some(
931
- (file) =>
932
- file === 'composer.json' ||
933
- file === 'composer.lock' ||
934
- file.endsWith('/composer.json') ||
935
- file.endsWith('/composer.lock')
936
- )
937
-
938
- const shouldRunMigrations =
939
- isLaravel &&
940
- changedFiles.some(
941
- (file) => file.startsWith('database/migrations/') && file.endsWith('.php')
942
- )
943
-
944
- const hasPhpChanges = isLaravel && changedFiles.some((file) => file.endsWith('.php'))
945
-
946
- const shouldRunNpmInstall =
947
- isLaravel &&
948
- changedFiles.some(
949
- (file) =>
950
- file === 'package.json' ||
951
- file === 'package-lock.json' ||
952
- file.endsWith('/package.json') ||
953
- file.endsWith('/package-lock.json')
954
- )
955
-
956
- const hasFrontendChanges =
957
- isLaravel &&
958
- changedFiles.some((file) =>
959
- ['.vue', '.css', '.scss', '.js', '.ts', '.tsx', '.less'].some((ext) =>
960
- file.endsWith(ext)
961
- )
962
- )
963
-
964
- const shouldRunBuild = isLaravel && (hasFrontendChanges || shouldRunNpmInstall)
965
- const shouldClearCaches = hasPhpChanges
966
- const shouldRestartQueues = hasPhpChanges
967
-
968
- let horizonConfigured = false
969
- if (shouldRestartQueues) {
970
- const horizonCheck = await ssh.execCommand(
971
- 'if [ -f config/horizon.php ]; then echo "yes"; else echo "no"; fi',
972
- { cwd: remoteCwd }
973
- )
974
- horizonConfigured = horizonCheck.stdout.trim() === 'yes'
975
- }
976
-
977
- const steps = [
978
- {
979
- label: `Pull latest changes for ${config.branch}`,
980
- command: `git pull origin ${config.branch}`
981
- }
982
- ]
983
-
984
- if (shouldRunComposer) {
985
- steps.push({
986
- label: 'Update Composer dependencies',
987
- command: 'composer update --no-dev --no-interaction --prefer-dist'
988
- })
989
- }
990
-
991
- if (shouldRunMigrations) {
992
- steps.push({
993
- label: 'Run database migrations',
994
- command: 'php artisan migrate --force'
995
- })
996
- }
997
-
998
- if (shouldRunNpmInstall) {
999
- steps.push({
1000
- label: 'Install Node dependencies',
1001
- command: 'npm install'
1002
- })
1003
- }
1004
-
1005
- if (shouldRunBuild) {
1006
- steps.push({
1007
- label: 'Compile frontend assets',
1008
- command: 'npm run build'
1009
- })
1010
- }
1011
-
1012
- if (shouldClearCaches) {
1013
- steps.push({
1014
- label: 'Clear Laravel caches',
1015
- command: 'php artisan cache:clear && php artisan config:clear && php artisan view:clear'
1016
- })
1017
- }
1018
-
1019
- if (shouldRestartQueues) {
1020
- steps.push({
1021
- label: horizonConfigured ? 'Restart Horizon workers' : 'Restart queue workers',
1022
- command: horizonConfigured ? 'php artisan horizon:terminate' : 'php artisan queue:restart'
1023
- })
1024
- }
1025
-
1026
- const usefulSteps = steps.length > 1
1027
-
1028
- let pendingSnapshot
1029
-
1030
- if (usefulSteps) {
1031
- pendingSnapshot = snapshot ?? {
1032
- serverName: config.serverName,
1033
- branch: config.branch,
1034
- projectPath: config.projectPath,
1035
- sshUser: config.sshUser,
1036
- createdAt: new Date().toISOString(),
1037
- changedFiles,
1038
- taskLabels: steps.map((step) => step.label)
1039
- }
1040
-
1041
- await savePendingTasksSnapshot(rootDir, pendingSnapshot)
1042
-
1043
- const payload = Buffer.from(JSON.stringify(pendingSnapshot)).toString('base64')
1044
- await executeRemote(
1045
- 'Record pending deployment tasks',
1046
- `mkdir -p .zephyr && echo '${payload}' | base64 --decode > .zephyr/${PENDING_TASKS_FILE}`,
1047
- { printStdout: false }
1048
- )
1049
- }
1050
-
1051
- if (steps.length === 1) {
1052
- logProcessing('No additional maintenance tasks scheduled beyond git pull.')
1053
- } else {
1054
- const extraTasks = steps
1055
- .slice(1)
1056
- .map((step) => step.label)
1057
- .join(', ')
1058
-
1059
- logProcessing(`Additional tasks scheduled: ${extraTasks}`)
1060
- }
1061
-
1062
- let completed = false
1063
-
1064
- try {
1065
- for (const step of steps) {
1066
- await executeRemote(step.label, step.command)
1067
- }
1068
-
1069
- completed = true
1070
- } finally {
1071
- if (usefulSteps && completed) {
1072
- await executeRemote(
1073
- 'Clear pending deployment snapshot',
1074
- `rm -f .zephyr/${PENDING_TASKS_FILE}`,
1075
- { printStdout: false, allowFailure: true }
1076
- )
1077
- await clearPendingTasksSnapshot(rootDir)
1078
- }
1079
- }
1080
-
1081
- logSuccess('\nDeployment commands completed successfully.')
1082
-
1083
- const logPath = await getLogFilePath(rootDir)
1084
- logSuccess(`\nAll task output has been logged to: ${logPath}`)
1085
- } catch (error) {
1086
- const logPath = logFilePath || await getLogFilePath(rootDir).catch(() => null)
1087
- if (logPath) {
1088
- logError(`\nTask output has been logged to: ${logPath}`)
1089
- }
1090
- throw new Error(`Deployment failed: ${error.message}`)
1091
- } finally {
1092
- await closeLogFile()
1093
- ssh.dispose()
1094
- }
1095
- }
1096
-
1097
- async function promptServerDetails(existingServers = []) {
1098
- const defaults = {
1099
- serverName: existingServers.length === 0 ? 'home' : `server-${existingServers.length + 1}`,
1100
- serverIp: '1.1.1.1'
1101
- }
1102
-
1103
- const answers = await runPrompt([
1104
- {
1105
- type: 'input',
1106
- name: 'serverName',
1107
- message: 'Server name',
1108
- default: defaults.serverName
1109
- },
1110
- {
1111
- type: 'input',
1112
- name: 'serverIp',
1113
- message: 'Server IP address',
1114
- default: defaults.serverIp
1115
- }
1116
- ])
1117
-
1118
- return {
1119
- serverName: answers.serverName.trim() || defaults.serverName,
1120
- serverIp: answers.serverIp.trim() || defaults.serverIp
1121
- }
1122
- }
1123
-
1124
- async function selectServer(servers) {
1125
- if (servers.length === 0) {
1126
- logProcessing("No servers configured. Let's create one.")
1127
- const server = await promptServerDetails()
1128
- servers.push(server)
1129
- await saveServers(servers)
1130
- logSuccess('Saved server configuration to ~/.config/zephyr/servers.json')
1131
- return server
1132
- }
1133
-
1134
- const choices = servers.map((server, index) => ({
1135
- name: `${server.serverName} (${server.serverIp})`,
1136
- value: index
1137
- }))
1138
-
1139
- choices.push(new inquirer.Separator(), {
1140
- name: '➕ Register a new server',
1141
- value: 'create'
1142
- })
1143
-
1144
- const { selection } = await runPrompt([
1145
- {
1146
- type: 'list',
1147
- name: 'selection',
1148
- message: 'Select server or register new',
1149
- choices,
1150
- default: 0
1151
- }
1152
- ])
1153
-
1154
- if (selection === 'create') {
1155
- const server = await promptServerDetails(servers)
1156
- servers.push(server)
1157
- await saveServers(servers)
1158
- logSuccess('Appended server configuration to ~/.config/zephyr/servers.json')
1159
- return server
1160
- }
1161
-
1162
- return servers[selection]
1163
- }
1164
-
1165
- async function promptAppDetails(currentDir, existing = {}) {
1166
- const branches = await listGitBranches(currentDir)
1167
- const defaultBranch = existing.branch || (branches.includes('master') ? 'master' : branches[0])
1168
- const defaults = {
1169
- projectPath: existing.projectPath || defaultProjectPath(currentDir),
1170
- branch: defaultBranch
1171
- }
1172
-
1173
- const answers = await runPrompt([
1174
- {
1175
- type: 'input',
1176
- name: 'projectPath',
1177
- message: 'Remote project path',
1178
- default: defaults.projectPath
1179
- },
1180
- {
1181
- type: 'list',
1182
- name: 'branchSelection',
1183
- message: 'Branch to deploy',
1184
- choices: [
1185
- ...branches.map((branch) => ({ name: branch, value: branch })),
1186
- new inquirer.Separator(),
1187
- { name: 'Enter custom branch…', value: '__custom' }
1188
- ],
1189
- default: defaults.branch
1190
- }
1191
- ])
1192
-
1193
- let branch = answers.branchSelection
1194
-
1195
- if (branch === '__custom') {
1196
- const { customBranch } = await runPrompt([
1197
- {
1198
- type: 'input',
1199
- name: 'customBranch',
1200
- message: 'Custom branch name',
1201
- default: defaults.branch
1202
- }
1203
- ])
1204
-
1205
- branch = customBranch.trim() || defaults.branch
1206
- }
1207
-
1208
- const sshDetails = await promptSshDetails(currentDir, existing)
1209
-
1210
- return {
1211
- projectPath: answers.projectPath.trim() || defaults.projectPath,
1212
- branch,
1213
- ...sshDetails
1214
- }
1215
- }
1216
-
1217
- async function selectApp(projectConfig, server, currentDir) {
1218
- const apps = projectConfig.apps ?? []
1219
- const matches = apps
1220
- .map((app, index) => ({ app, index }))
1221
- .filter(({ app }) => app.serverName === server.serverName)
1222
-
1223
- if (matches.length === 0) {
1224
- logProcessing(`No applications configured for ${server.serverName}. Let's create one.`)
1225
- const appDetails = await promptAppDetails(currentDir)
1226
- const appConfig = {
1227
- serverName: server.serverName,
1228
- ...appDetails
1229
- }
1230
- projectConfig.apps.push(appConfig)
1231
- await saveProjectConfig(currentDir, projectConfig)
1232
- logSuccess('Saved deployment configuration to .zephyr/config.json')
1233
- return appConfig
1234
- }
1235
-
1236
- const choices = matches.map(({ app, index }) => ({
1237
- name: `${app.projectPath} (${app.branch})`,
1238
- value: index
1239
- }))
1240
-
1241
- choices.push(new inquirer.Separator(), {
1242
- name: '➕ Configure new application for this server',
1243
- value: 'create'
1244
- })
1245
-
1246
- const { selection } = await runPrompt([
1247
- {
1248
- type: 'list',
1249
- name: 'selection',
1250
- message: `Select application for ${server.serverName}`,
1251
- choices,
1252
- default: 0
1253
- }
1254
- ])
1255
-
1256
- if (selection === 'create') {
1257
- const appDetails = await promptAppDetails(currentDir)
1258
- const appConfig = {
1259
- serverName: server.serverName,
1260
- ...appDetails
1261
- }
1262
- projectConfig.apps.push(appConfig)
1263
- await saveProjectConfig(currentDir, projectConfig)
1264
- logSuccess('Appended deployment configuration to .zephyr/config.json')
1265
- return appConfig
1266
- }
1267
-
1268
- const chosen = projectConfig.apps[selection]
1269
- return chosen
1270
- }
1271
-
1272
- async function main() {
1273
- const rootDir = process.cwd()
1274
-
1275
- await ensureGitignoreEntry(rootDir)
1276
- await ensureProjectReleaseScript(rootDir)
1277
-
1278
- const servers = await loadServers()
1279
- const server = await selectServer(servers)
1280
- const projectConfig = await loadProjectConfig(rootDir)
1281
- const appConfig = await selectApp(projectConfig, server, rootDir)
1282
-
1283
- const updated = await ensureSshDetails(appConfig, rootDir)
1284
-
1285
- if (updated) {
1286
- await saveProjectConfig(rootDir, projectConfig)
1287
- logSuccess('Updated .zephyr/config.json with SSH details.')
1288
- }
1289
-
1290
- const deploymentConfig = {
1291
- serverName: server.serverName,
1292
- serverIp: server.serverIp,
1293
- projectPath: appConfig.projectPath,
1294
- branch: appConfig.branch,
1295
- sshUser: appConfig.sshUser,
1296
- sshKey: appConfig.sshKey
1297
- }
1298
-
1299
- logProcessing('\nSelected deployment target:')
1300
- console.log(JSON.stringify(deploymentConfig, null, 2))
1301
-
1302
- const existingSnapshot = await loadPendingTasksSnapshot(rootDir)
1303
- let snapshotToUse = null
1304
-
1305
- if (existingSnapshot) {
1306
- const matchesSelection =
1307
- existingSnapshot.serverName === deploymentConfig.serverName &&
1308
- existingSnapshot.branch === deploymentConfig.branch
1309
-
1310
- const messageLines = [
1311
- 'Pending deployment tasks were detected from a previous run.',
1312
- `Server: ${existingSnapshot.serverName}`,
1313
- `Branch: ${existingSnapshot.branch}`
1314
- ]
1315
-
1316
- if (existingSnapshot.taskLabels && existingSnapshot.taskLabels.length > 0) {
1317
- messageLines.push(`Tasks: ${existingSnapshot.taskLabels.join(', ')}`)
1318
- }
1319
-
1320
- const { resumePendingTasks } = await runPrompt([
1321
- {
1322
- type: 'confirm',
1323
- name: 'resumePendingTasks',
1324
- message: `${messageLines.join(' | ')}. Resume using this plan?`,
1325
- default: matchesSelection
1326
- }
1327
- ])
1328
-
1329
- if (resumePendingTasks) {
1330
- snapshotToUse = existingSnapshot
1331
- logProcessing('Resuming deployment using saved task snapshot...')
1332
- } else {
1333
- await clearPendingTasksSnapshot(rootDir)
1334
- logWarning('Discarded pending deployment snapshot.')
1335
- }
1336
- }
1337
-
1338
- let lockAcquired = false
1339
-
1340
- try {
1341
- await acquireProjectLock(rootDir)
1342
- lockAcquired = true
1343
- await runRemoteTasks(deploymentConfig, { rootDir, snapshot: snapshotToUse })
1344
- } finally {
1345
- if (lockAcquired) {
1346
- await releaseProjectLock(rootDir)
1347
- }
1348
- }
1349
- }
1350
-
1351
- export {
1352
- ensureGitignoreEntry,
1353
- ensureProjectReleaseScript,
1354
- listSshKeys,
1355
- resolveRemotePath,
1356
- isPrivateKeyFile,
1357
- runRemoteTasks,
1358
- promptServerDetails,
1359
- selectServer,
1360
- promptAppDetails,
1361
- selectApp,
1362
- promptSshDetails,
1363
- ensureSshDetails,
1364
- ensureLocalRepositoryState,
1365
- loadServers,
1366
- loadProjectConfig,
1367
- main
1368
- }
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 chalk from 'chalk'
6
+ import inquirer from 'inquirer'
7
+ import { NodeSSH } from 'node-ssh'
8
+
9
+ const PROJECT_CONFIG_DIR = '.zephyr'
10
+ const PROJECT_CONFIG_FILE = 'config.json'
11
+ const GLOBAL_CONFIG_DIR = path.join(os.homedir(), '.config', 'zephyr')
12
+ const SERVERS_FILE = path.join(GLOBAL_CONFIG_DIR, 'servers.json')
13
+ const PROJECT_LOCK_FILE = 'deploy.lock'
14
+ const PENDING_TASKS_FILE = 'pending-tasks.json'
15
+ const RELEASE_SCRIPT_NAME = 'release'
16
+ const RELEASE_SCRIPT_COMMAND = 'npx @wyxos/zephyr@latest'
17
+
18
+ const logProcessing = (message = '') => console.log(chalk.yellow(message))
19
+ const logSuccess = (message = '') => console.log(chalk.green(message))
20
+ const logWarning = (message = '') => console.warn(chalk.yellow(message))
21
+ const logError = (message = '') => console.error(chalk.red(message))
22
+
23
+ let logFilePath = null
24
+
25
+ async function getLogFilePath(rootDir) {
26
+ if (logFilePath) {
27
+ return logFilePath
28
+ }
29
+
30
+ const configDir = getProjectConfigDir(rootDir)
31
+ await ensureDirectory(configDir)
32
+
33
+ const now = new Date()
34
+ const dateStr = now.toISOString().replace(/:/g, '-').replace(/\..+/, '')
35
+ logFilePath = path.join(configDir, `${dateStr}.log`)
36
+
37
+ return logFilePath
38
+ }
39
+
40
+ async function writeToLogFile(rootDir, message) {
41
+ const logPath = await getLogFilePath(rootDir)
42
+ const timestamp = new Date().toISOString()
43
+ await fs.appendFile(logPath, `${timestamp} - ${message}\n`)
44
+ }
45
+
46
+ async function closeLogFile() {
47
+ logFilePath = null
48
+ }
49
+
50
+ const createSshClient = () => {
51
+ if (typeof globalThis !== 'undefined' && globalThis.__zephyrSSHFactory) {
52
+ return globalThis.__zephyrSSHFactory()
53
+ }
54
+
55
+ return new NodeSSH()
56
+ }
57
+
58
+ const runPrompt = async (questions) => {
59
+ if (typeof globalThis !== 'undefined' && globalThis.__zephyrPrompt) {
60
+ return globalThis.__zephyrPrompt(questions)
61
+ }
62
+
63
+ return inquirer.prompt(questions)
64
+ }
65
+
66
+ async function runCommand(command, args, { silent = false, cwd } = {}) {
67
+ return new Promise((resolve, reject) => {
68
+ const child = spawn(command, args, {
69
+ stdio: silent ? 'ignore' : 'inherit',
70
+ cwd
71
+ })
72
+
73
+ child.on('error', reject)
74
+ child.on('close', (code) => {
75
+ if (code === 0) {
76
+ resolve()
77
+ } else {
78
+ const error = new Error(`${command} exited with code ${code}`)
79
+ error.exitCode = code
80
+ reject(error)
81
+ }
82
+ })
83
+ })
84
+ }
85
+
86
+ async function runCommandCapture(command, args, { cwd } = {}) {
87
+ return new Promise((resolve, reject) => {
88
+ let stdout = ''
89
+ let stderr = ''
90
+
91
+ const child = spawn(command, args, {
92
+ stdio: ['ignore', 'pipe', 'pipe'],
93
+ cwd
94
+ })
95
+
96
+ child.stdout.on('data', (chunk) => {
97
+ stdout += chunk
98
+ })
99
+
100
+ child.stderr.on('data', (chunk) => {
101
+ stderr += chunk
102
+ })
103
+
104
+ child.on('error', reject)
105
+ child.on('close', (code) => {
106
+ if (code === 0) {
107
+ resolve(stdout)
108
+ } else {
109
+ const error = new Error(`${command} exited with code ${code}: ${stderr.trim()}`)
110
+ error.exitCode = code
111
+ reject(error)
112
+ }
113
+ })
114
+ })
115
+ }
116
+
117
+ async function getCurrentBranch(rootDir) {
118
+ const output = await runCommandCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
119
+ cwd: rootDir
120
+ })
121
+
122
+ return output.trim()
123
+ }
124
+
125
+ async function getGitStatus(rootDir) {
126
+ const output = await runCommandCapture('git', ['status', '--porcelain'], {
127
+ cwd: rootDir
128
+ })
129
+
130
+ return output.trim()
131
+ }
132
+
133
+ function hasStagedChanges(statusOutput) {
134
+ if (!statusOutput || statusOutput.length === 0) {
135
+ return false
136
+ }
137
+
138
+ const lines = statusOutput.split('\n').filter((line) => line.trim().length > 0)
139
+
140
+ return lines.some((line) => {
141
+ const firstChar = line[0]
142
+ // In git status --porcelain format:
143
+ // - First char is space: unstaged changes (e.g., " M file")
144
+ // - First char is '?': untracked files (e.g., "?? file")
145
+ // - First char is letter (M, A, D, etc.): staged changes (e.g., "M file")
146
+ // Only return true for staged changes, not unstaged or untracked
147
+ return firstChar && firstChar !== ' ' && firstChar !== '?'
148
+ })
149
+ }
150
+
151
+ async function getUpstreamRef(rootDir) {
152
+ try {
153
+ const output = await runCommandCapture('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], {
154
+ cwd: rootDir
155
+ })
156
+
157
+ const ref = output.trim()
158
+ return ref.length > 0 ? ref : null
159
+ } catch {
160
+ return null
161
+ }
162
+ }
163
+
164
+ async function ensureCommittedChangesPushed(targetBranch, rootDir) {
165
+ const upstreamRef = await getUpstreamRef(rootDir)
166
+
167
+ if (!upstreamRef) {
168
+ logWarning(`Branch ${targetBranch} does not track a remote upstream; skipping automatic push of committed changes.`)
169
+ return { pushed: false, upstreamRef: null }
170
+ }
171
+
172
+ const [remoteName, ...upstreamParts] = upstreamRef.split('/')
173
+ const upstreamBranch = upstreamParts.join('/')
174
+
175
+ if (!remoteName || !upstreamBranch) {
176
+ logWarning(`Unable to determine remote destination for ${targetBranch}. Skipping automatic push.`)
177
+ return { pushed: false, upstreamRef }
178
+ }
179
+
180
+ try {
181
+ await runCommand('git', ['fetch', remoteName], { cwd: rootDir, silent: true })
182
+ } catch (error) {
183
+ logWarning(`Unable to fetch from ${remoteName} before push: ${error.message}`)
184
+ }
185
+
186
+ let remoteExists = true
187
+
188
+ try {
189
+ await runCommand('git', ['show-ref', '--verify', '--quiet', `refs/remotes/${upstreamRef}`], {
190
+ cwd: rootDir,
191
+ silent: true
192
+ })
193
+ } catch {
194
+ remoteExists = false
195
+ }
196
+
197
+ let aheadCount = 0
198
+ let behindCount = 0
199
+
200
+ if (remoteExists) {
201
+ const aheadOutput = await runCommandCapture('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
202
+ cwd: rootDir
203
+ })
204
+
205
+ aheadCount = parseInt(aheadOutput.trim() || '0', 10)
206
+
207
+ const behindOutput = await runCommandCapture('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
208
+ cwd: rootDir
209
+ })
210
+
211
+ behindCount = parseInt(behindOutput.trim() || '0', 10)
212
+ } else {
213
+ aheadCount = 1
214
+ }
215
+
216
+ if (Number.isFinite(behindCount) && behindCount > 0) {
217
+ throw new Error(
218
+ `Local branch ${targetBranch} is behind ${upstreamRef} by ${behindCount} commit${behindCount === 1 ? '' : 's'}. Pull or rebase before deployment.`
219
+ )
220
+ }
221
+
222
+ if (!Number.isFinite(aheadCount) || aheadCount <= 0) {
223
+ return { pushed: false, upstreamRef }
224
+ }
225
+
226
+ const commitLabel = aheadCount === 1 ? 'commit' : 'commits'
227
+ logProcessing(`Found ${aheadCount} ${commitLabel} not yet pushed to ${upstreamRef}. Pushing before deployment...`)
228
+
229
+ await runCommand('git', ['push', remoteName, `${targetBranch}:${upstreamBranch}`], { cwd: rootDir })
230
+ logSuccess(`Pushed committed changes to ${upstreamRef}.`)
231
+
232
+ return { pushed: true, upstreamRef }
233
+ }
234
+
235
+ async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd()) {
236
+ if (!targetBranch) {
237
+ throw new Error('Deployment branch is not defined in the release configuration.')
238
+ }
239
+
240
+ const currentBranch = await getCurrentBranch(rootDir)
241
+
242
+ if (!currentBranch) {
243
+ throw new Error('Unable to determine the current git branch. Ensure this is a git repository.')
244
+ }
245
+
246
+ const initialStatus = await getGitStatus(rootDir)
247
+ const hasPendingChanges = initialStatus.length > 0
248
+
249
+ const statusReport = await runCommandCapture('git', ['status', '--short', '--branch'], {
250
+ cwd: rootDir
251
+ })
252
+
253
+ const lines = statusReport.split(/\r?\n/)
254
+ const branchLine = lines[0] || ''
255
+ const aheadMatch = branchLine.match(/ahead (\d+)/)
256
+ const behindMatch = branchLine.match(/behind (\d+)/)
257
+ const aheadCount = aheadMatch ? parseInt(aheadMatch[1], 10) : 0
258
+ const behindCount = behindMatch ? parseInt(behindMatch[1], 10) : 0
259
+
260
+ if (aheadCount > 0) {
261
+ logWarning(`Local branch ${currentBranch} is ahead of upstream by ${aheadCount} commit${aheadCount === 1 ? '' : 's'}.`)
262
+ }
263
+
264
+ if (behindCount > 0) {
265
+ logProcessing(`Synchronizing local branch ${currentBranch} with its upstream...`)
266
+ try {
267
+ await runCommand('git', ['pull', '--ff-only'], { cwd: rootDir })
268
+ logSuccess('Local branch fast-forwarded with upstream changes.')
269
+ } catch (error) {
270
+ throw new Error(
271
+ `Unable to fast-forward ${currentBranch} with upstream changes. Resolve conflicts manually, then rerun the deployment.\n${error.message}`
272
+ )
273
+ }
274
+ }
275
+
276
+ if (currentBranch !== targetBranch) {
277
+ if (hasPendingChanges) {
278
+ throw new Error(
279
+ `Local repository has uncommitted changes on ${currentBranch}. Commit or stash them before switching to ${targetBranch}.`
280
+ )
281
+ }
282
+
283
+ logProcessing(`Switching local repository from ${currentBranch} to ${targetBranch}...`)
284
+ await runCommand('git', ['checkout', targetBranch], { cwd: rootDir })
285
+ logSuccess(`Checked out ${targetBranch} locally.`)
286
+ }
287
+
288
+ const statusAfterCheckout = currentBranch === targetBranch ? initialStatus : await getGitStatus(rootDir)
289
+
290
+ if (statusAfterCheckout.length === 0) {
291
+ await ensureCommittedChangesPushed(targetBranch, rootDir)
292
+ logProcessing('Local repository is clean. Proceeding with deployment.')
293
+ return
294
+ }
295
+
296
+ if (!hasStagedChanges(statusAfterCheckout)) {
297
+ await ensureCommittedChangesPushed(targetBranch, rootDir)
298
+ logProcessing('No staged changes detected. Unstaged or untracked files will not affect deployment. Proceeding with deployment.')
299
+ return
300
+ }
301
+
302
+ logWarning(`Staged changes detected on ${targetBranch}. A commit is required before deployment.`)
303
+
304
+ const { commitMessage } = await runPrompt([
305
+ {
306
+ type: 'input',
307
+ name: 'commitMessage',
308
+ message: 'Enter a commit message for pending changes before deployment',
309
+ validate: (value) => (value && value.trim().length > 0 ? true : 'Commit message cannot be empty.')
310
+ }
311
+ ])
312
+
313
+ const message = commitMessage.trim()
314
+
315
+ logProcessing('Committing staged changes before deployment...')
316
+ await runCommand('git', ['commit', '-m', message], { cwd: rootDir })
317
+ await runCommand('git', ['push', 'origin', targetBranch], { cwd: rootDir })
318
+ logSuccess(`Committed and pushed changes to origin/${targetBranch}.`)
319
+
320
+ const finalStatus = await getGitStatus(rootDir)
321
+
322
+ if (finalStatus.length > 0) {
323
+ throw new Error('Local repository still has uncommitted changes after commit. Aborting deployment.')
324
+ }
325
+
326
+ await ensureCommittedChangesPushed(targetBranch, rootDir)
327
+ logProcessing('Local repository is clean after committing pending changes.')
328
+ }
329
+
330
+ async function ensureProjectReleaseScript(rootDir) {
331
+ const packageJsonPath = path.join(rootDir, 'package.json')
332
+
333
+ let raw
334
+ try {
335
+ raw = await fs.readFile(packageJsonPath, 'utf8')
336
+ } catch (error) {
337
+ if (error.code === 'ENOENT') {
338
+ return false
339
+ }
340
+
341
+ throw error
342
+ }
343
+
344
+ let packageJson
345
+ try {
346
+ packageJson = JSON.parse(raw)
347
+ } catch (error) {
348
+ logWarning('Unable to parse package.json; skipping release script injection.')
349
+ return false
350
+ }
351
+
352
+ const currentCommand = packageJson?.scripts?.[RELEASE_SCRIPT_NAME]
353
+
354
+ if (currentCommand && currentCommand.includes('@wyxos/zephyr')) {
355
+ return false
356
+ }
357
+
358
+ const { installReleaseScript } = await runPrompt([
359
+ {
360
+ type: 'confirm',
361
+ name: 'installReleaseScript',
362
+ message: 'Add "release" script to package.json that runs "npx @wyxos/zephyr@latest"?',
363
+ default: true
364
+ }
365
+ ])
366
+
367
+ if (!installReleaseScript) {
368
+ return false
369
+ }
370
+
371
+ if (!packageJson.scripts || typeof packageJson.scripts !== 'object') {
372
+ packageJson.scripts = {}
373
+ }
374
+
375
+ packageJson.scripts[RELEASE_SCRIPT_NAME] = RELEASE_SCRIPT_COMMAND
376
+
377
+ const updatedPayload = `${JSON.stringify(packageJson, null, 2)}\n`
378
+ await fs.writeFile(packageJsonPath, updatedPayload)
379
+ logSuccess('Added release script to package.json.')
380
+
381
+ let isGitRepo = false
382
+
383
+ try {
384
+ await runCommand('git', ['rev-parse', '--is-inside-work-tree'], { cwd: rootDir, silent: true })
385
+ isGitRepo = true
386
+ } catch (error) {
387
+ logWarning('Not a git repository; skipping commit for release script addition.')
388
+ }
389
+
390
+ if (isGitRepo) {
391
+ try {
392
+ await runCommand('git', ['add', 'package.json'], { cwd: rootDir, silent: true })
393
+ await runCommand('git', ['commit', '-m', 'chore: add zephyr release script'], { cwd: rootDir, silent: true })
394
+ logSuccess('Committed package.json release script addition.')
395
+ } catch (error) {
396
+ if (error.exitCode === 1) {
397
+ logWarning('Git commit skipped: nothing to commit or pre-commit hook prevented commit.')
398
+ } else {
399
+ throw error
400
+ }
401
+ }
402
+ }
403
+
404
+ return true
405
+ }
406
+
407
+ function getProjectConfigDir(rootDir) {
408
+ return path.join(rootDir, PROJECT_CONFIG_DIR)
409
+ }
410
+
411
+ function getPendingTasksPath(rootDir) {
412
+ return path.join(getProjectConfigDir(rootDir), PENDING_TASKS_FILE)
413
+ }
414
+
415
+ function getLockFilePath(rootDir) {
416
+ return path.join(getProjectConfigDir(rootDir), PROJECT_LOCK_FILE)
417
+ }
418
+
419
+ async function acquireProjectLock(rootDir) {
420
+ const lockDir = getProjectConfigDir(rootDir)
421
+ await ensureDirectory(lockDir)
422
+ const lockPath = getLockFilePath(rootDir)
423
+
424
+ try {
425
+ const existing = await fs.readFile(lockPath, 'utf8')
426
+ let details = {}
427
+ try {
428
+ details = JSON.parse(existing)
429
+ } catch (error) {
430
+ details = { raw: existing }
431
+ }
432
+
433
+ const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
434
+ const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
435
+ throw new Error(
436
+ `Another deployment is currently in progress (started by ${startedBy}${startedAt}). Remove ${lockPath} if you are sure it is stale.`
437
+ )
438
+ } catch (error) {
439
+ if (error.code !== 'ENOENT') {
440
+ throw error
441
+ }
442
+ }
443
+
444
+ const payload = {
445
+ user: os.userInfo().username,
446
+ pid: process.pid,
447
+ hostname: os.hostname(),
448
+ startedAt: new Date().toISOString()
449
+ }
450
+
451
+ await fs.writeFile(lockPath, `${JSON.stringify(payload, null, 2)}\n`)
452
+ return lockPath
453
+ }
454
+
455
+ async function releaseProjectLock(rootDir) {
456
+ const lockPath = getLockFilePath(rootDir)
457
+ try {
458
+ await fs.unlink(lockPath)
459
+ } catch (error) {
460
+ if (error.code !== 'ENOENT') {
461
+ throw error
462
+ }
463
+ }
464
+ }
465
+
466
+ async function loadPendingTasksSnapshot(rootDir) {
467
+ const snapshotPath = getPendingTasksPath(rootDir)
468
+
469
+ try {
470
+ const raw = await fs.readFile(snapshotPath, 'utf8')
471
+ return JSON.parse(raw)
472
+ } catch (error) {
473
+ if (error.code === 'ENOENT') {
474
+ return null
475
+ }
476
+
477
+ throw error
478
+ }
479
+ }
480
+
481
+ async function savePendingTasksSnapshot(rootDir, snapshot) {
482
+ const configDir = getProjectConfigDir(rootDir)
483
+ await ensureDirectory(configDir)
484
+ const payload = `${JSON.stringify(snapshot, null, 2)}\n`
485
+ await fs.writeFile(getPendingTasksPath(rootDir), payload)
486
+ }
487
+
488
+ async function clearPendingTasksSnapshot(rootDir) {
489
+ try {
490
+ await fs.unlink(getPendingTasksPath(rootDir))
491
+ } catch (error) {
492
+ if (error.code !== 'ENOENT') {
493
+ throw error
494
+ }
495
+ }
496
+ }
497
+
498
+ async function ensureGitignoreEntry(rootDir) {
499
+ const gitignorePath = path.join(rootDir, '.gitignore')
500
+ const targetEntry = `${PROJECT_CONFIG_DIR}/`
501
+ let existingContent = ''
502
+
503
+ try {
504
+ existingContent = await fs.readFile(gitignorePath, 'utf8')
505
+ } catch (error) {
506
+ if (error.code !== 'ENOENT') {
507
+ throw error
508
+ }
509
+ }
510
+
511
+ const hasEntry = existingContent
512
+ .split(/\r?\n/)
513
+ .some((line) => line.trim() === targetEntry)
514
+
515
+ if (hasEntry) {
516
+ return
517
+ }
518
+
519
+ const updatedContent = existingContent
520
+ ? `${existingContent.replace(/\s*$/, '')}\n${targetEntry}\n`
521
+ : `${targetEntry}\n`
522
+
523
+ await fs.writeFile(gitignorePath, updatedContent)
524
+ logSuccess('Added .zephyr/ to .gitignore')
525
+
526
+ let isGitRepo = false
527
+ try {
528
+ await runCommand('git', ['rev-parse', '--is-inside-work-tree'], {
529
+ silent: true,
530
+ cwd: rootDir
531
+ })
532
+ isGitRepo = true
533
+ } catch (error) {
534
+ logWarning('Not a git repository; skipping commit for .gitignore update.')
535
+ }
536
+
537
+ if (!isGitRepo) {
538
+ return
539
+ }
540
+
541
+ try {
542
+ await runCommand('git', ['add', '.gitignore'], { cwd: rootDir })
543
+ await runCommand('git', ['commit', '-m', 'chore: ignore zephyr config'], { cwd: rootDir })
544
+ } catch (error) {
545
+ if (error.exitCode === 1) {
546
+ logWarning('Git commit skipped: nothing to commit or pre-commit hook prevented commit.')
547
+ } else {
548
+ throw error
549
+ }
550
+ }
551
+ }
552
+
553
+ async function ensureDirectory(dirPath) {
554
+ await fs.mkdir(dirPath, { recursive: true })
555
+ }
556
+
557
+ async function loadServers() {
558
+ try {
559
+ const raw = await fs.readFile(SERVERS_FILE, 'utf8')
560
+ const data = JSON.parse(raw)
561
+ return Array.isArray(data) ? data : []
562
+ } catch (error) {
563
+ if (error.code === 'ENOENT') {
564
+ return []
565
+ }
566
+
567
+ logWarning('Failed to read servers.json, starting with an empty list.')
568
+ return []
569
+ }
570
+ }
571
+
572
+ async function saveServers(servers) {
573
+ await ensureDirectory(GLOBAL_CONFIG_DIR)
574
+ const payload = JSON.stringify(servers, null, 2)
575
+ await fs.writeFile(SERVERS_FILE, `${payload}\n`)
576
+ }
577
+
578
+ function getProjectConfigPath(rootDir) {
579
+ return path.join(rootDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE)
580
+ }
581
+
582
+ async function loadProjectConfig(rootDir) {
583
+ const configPath = getProjectConfigPath(rootDir)
584
+
585
+ try {
586
+ const raw = await fs.readFile(configPath, 'utf8')
587
+ const data = JSON.parse(raw)
588
+ return {
589
+ apps: Array.isArray(data?.apps) ? data.apps : []
590
+ }
591
+ } catch (error) {
592
+ if (error.code === 'ENOENT') {
593
+ return { apps: [] }
594
+ }
595
+
596
+ logWarning('Failed to read .zephyr/config.json, starting with an empty list of apps.')
597
+ return { apps: [] }
598
+ }
599
+ }
600
+
601
+ async function saveProjectConfig(rootDir, config) {
602
+ const configDir = path.join(rootDir, PROJECT_CONFIG_DIR)
603
+ await ensureDirectory(configDir)
604
+ const payload = JSON.stringify({ apps: config.apps ?? [] }, null, 2)
605
+ await fs.writeFile(path.join(configDir, PROJECT_CONFIG_FILE), `${payload}\n`)
606
+ }
607
+
608
+ function defaultProjectPath(currentDir) {
609
+ return `~/webapps/${path.basename(currentDir)}`
610
+ }
611
+
612
+ async function listGitBranches(currentDir) {
613
+ try {
614
+ const output = await runCommandCapture(
615
+ 'git',
616
+ ['branch', '--format', '%(refname:short)'],
617
+ { cwd: currentDir }
618
+ )
619
+
620
+ const branches = output
621
+ .split(/\r?\n/)
622
+ .map((line) => line.trim())
623
+ .filter(Boolean)
624
+
625
+ return branches.length ? branches : ['master']
626
+ } catch (error) {
627
+ logWarning('Unable to read git branches; defaulting to master.')
628
+ return ['master']
629
+ }
630
+ }
631
+
632
+ async function listSshKeys() {
633
+ const sshDir = path.join(os.homedir(), '.ssh')
634
+
635
+ try {
636
+ const entries = await fs.readdir(sshDir, { withFileTypes: true })
637
+
638
+ const candidates = entries
639
+ .filter((entry) => entry.isFile())
640
+ .map((entry) => entry.name)
641
+ .filter((name) => {
642
+ if (!name) return false
643
+ if (name.startsWith('.')) return false
644
+ if (name.endsWith('.pub')) return false
645
+ if (name.startsWith('known_hosts')) return false
646
+ if (name === 'config') return false
647
+ return name.trim().length > 0
648
+ })
649
+
650
+ const keys = []
651
+
652
+ for (const name of candidates) {
653
+ const filePath = path.join(sshDir, name)
654
+ if (await isPrivateKeyFile(filePath)) {
655
+ keys.push(name)
656
+ }
657
+ }
658
+
659
+ return {
660
+ sshDir,
661
+ keys
662
+ }
663
+ } catch (error) {
664
+ if (error.code === 'ENOENT') {
665
+ return {
666
+ sshDir,
667
+ keys: []
668
+ }
669
+ }
670
+
671
+ throw error
672
+ }
673
+ }
674
+
675
+ async function isPrivateKeyFile(filePath) {
676
+ try {
677
+ const content = await fs.readFile(filePath, 'utf8')
678
+ return /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(content)
679
+ } catch (error) {
680
+ return false
681
+ }
682
+ }
683
+
684
+ async function promptSshDetails(currentDir, existing = {}) {
685
+ const { sshDir, keys: sshKeys } = await listSshKeys()
686
+ const defaultUser = existing.sshUser || os.userInfo().username
687
+ const fallbackKey = path.join(sshDir, 'id_rsa')
688
+ const preselectedKey = existing.sshKey || (sshKeys.length ? path.join(sshDir, sshKeys[0]) : fallbackKey)
689
+
690
+ const sshKeyPrompt = sshKeys.length
691
+ ? {
692
+ type: 'list',
693
+ name: 'sshKeySelection',
694
+ message: 'SSH key',
695
+ choices: [
696
+ ...sshKeys.map((key) => ({ name: key, value: path.join(sshDir, key) })),
697
+ new inquirer.Separator(),
698
+ { name: 'Enter custom SSH key path…', value: '__custom' }
699
+ ],
700
+ default: preselectedKey
701
+ }
702
+ : {
703
+ type: 'input',
704
+ name: 'sshKeySelection',
705
+ message: 'SSH key path',
706
+ default: preselectedKey
707
+ }
708
+
709
+ const answers = await runPrompt([
710
+ {
711
+ type: 'input',
712
+ name: 'sshUser',
713
+ message: 'SSH user',
714
+ default: defaultUser
715
+ },
716
+ sshKeyPrompt
717
+ ])
718
+
719
+ let sshKey = answers.sshKeySelection
720
+
721
+ if (sshKey === '__custom') {
722
+ const { customSshKey } = await runPrompt([
723
+ {
724
+ type: 'input',
725
+ name: 'customSshKey',
726
+ message: 'SSH key path',
727
+ default: preselectedKey
728
+ }
729
+ ])
730
+
731
+ sshKey = customSshKey.trim() || preselectedKey
732
+ }
733
+
734
+ return {
735
+ sshUser: answers.sshUser.trim() || defaultUser,
736
+ sshKey: sshKey.trim() || preselectedKey
737
+ }
738
+ }
739
+
740
+ async function ensureSshDetails(config, currentDir) {
741
+ if (config.sshUser && config.sshKey) {
742
+ return false
743
+ }
744
+
745
+ logProcessing('SSH details missing. Please provide them now.')
746
+ const details = await promptSshDetails(currentDir, config)
747
+ Object.assign(config, details)
748
+ return true
749
+ }
750
+
751
+ function expandHomePath(targetPath) {
752
+ if (!targetPath) {
753
+ return targetPath
754
+ }
755
+
756
+ if (targetPath.startsWith('~')) {
757
+ return path.join(os.homedir(), targetPath.slice(1))
758
+ }
759
+
760
+ return targetPath
761
+ }
762
+
763
+ async function resolveSshKeyPath(targetPath) {
764
+ const expanded = expandHomePath(targetPath)
765
+
766
+ try {
767
+ await fs.access(expanded)
768
+ } catch (error) {
769
+ throw new Error(`SSH key not accessible at ${expanded}`)
770
+ }
771
+
772
+ return expanded
773
+ }
774
+
775
+ function resolveRemotePath(projectPath, remoteHome) {
776
+ if (!projectPath) {
777
+ return projectPath
778
+ }
779
+
780
+ const sanitizedHome = remoteHome.replace(/\/+$/, '')
781
+
782
+ if (projectPath === '~') {
783
+ return sanitizedHome
784
+ }
785
+
786
+ if (projectPath.startsWith('~/')) {
787
+ const remainder = projectPath.slice(2)
788
+ return remainder ? `${sanitizedHome}/${remainder}` : sanitizedHome
789
+ }
790
+
791
+ if (projectPath.startsWith('/')) {
792
+ return projectPath
793
+ }
794
+
795
+ return `${sanitizedHome}/${projectPath}`
796
+ }
797
+
798
+ async function runRemoteTasks(config, options = {}) {
799
+ const { snapshot = null, rootDir = process.cwd() } = options
800
+
801
+ await ensureLocalRepositoryState(config.branch, rootDir)
802
+
803
+ const ssh = createSshClient()
804
+ const sshUser = config.sshUser || os.userInfo().username
805
+ const privateKeyPath = await resolveSshKeyPath(config.sshKey)
806
+ const privateKey = await fs.readFile(privateKeyPath, 'utf8')
807
+
808
+ logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
809
+
810
+ try {
811
+ await ssh.connect({
812
+ host: config.serverIp,
813
+ username: sshUser,
814
+ privateKey
815
+ })
816
+
817
+ const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
818
+ const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
819
+ const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
820
+
821
+ logProcessing(`Connection established. Running deployment commands in ${remoteCwd}...`)
822
+
823
+ // Robust environment bootstrap that works even when profile files don't export PATH
824
+ // for non-interactive shells. This handles:
825
+ // 1. Sourcing profile files (may not export PATH for non-interactive shells)
826
+ // 2. Loading nvm if available (common Node.js installation method)
827
+ // 3. Finding and adding common Node.js/npm installation paths
828
+ const profileBootstrap = [
829
+ // Source profile files (may set PATH, but often skip for non-interactive shells)
830
+ 'if [ -f "$HOME/.profile" ]; then . "$HOME/.profile"; fi',
831
+ 'if [ -f "$HOME/.bash_profile" ]; then . "$HOME/.bash_profile"; fi',
832
+ 'if [ -f "$HOME/.bashrc" ]; then . "$HOME/.bashrc"; fi',
833
+ 'if [ -f "$HOME/.zprofile" ]; then . "$HOME/.zprofile"; fi',
834
+ 'if [ -f "$HOME/.zshrc" ]; then . "$HOME/.zshrc"; fi',
835
+ // Load nvm if available (common Node.js installation method)
836
+ 'if [ -s "$HOME/.nvm/nvm.sh" ]; then . "$HOME/.nvm/nvm.sh"; fi',
837
+ 'if [ -s "$HOME/.config/nvm/nvm.sh" ]; then . "$HOME/.config/nvm/nvm.sh"; fi',
838
+ 'if [ -s "/usr/local/opt/nvm/nvm.sh" ]; then . "/usr/local/opt/nvm/nvm.sh"; fi',
839
+ // Try to find npm/node in common locations and add to PATH
840
+ 'if command -v npm >/dev/null 2>&1; then :',
841
+ '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"',
842
+ 'elif [ -d "/usr/local/lib/node_modules/npm/bin" ]; then export PATH="/usr/local/lib/node_modules/npm/bin:$PATH"',
843
+ 'elif [ -d "/opt/homebrew/bin" ] && [ -f "/opt/homebrew/bin/npm" ]; then export PATH="/opt/homebrew/bin:$PATH"',
844
+ 'elif [ -d "/usr/local/bin" ] && [ -f "/usr/local/bin/npm" ]; then export PATH="/usr/local/bin:$PATH"',
845
+ 'elif [ -d "$HOME/.local/bin" ] && [ -f "$HOME/.local/bin/npm" ]; then export PATH="$HOME/.local/bin:$PATH"',
846
+ 'fi'
847
+ ].join('; ')
848
+
849
+ const escapeForDoubleQuotes = (value) => value.replace(/(["\\$`])/g, '\\$1')
850
+
851
+ const executeRemote = async (label, command, options = {}) => {
852
+ const { cwd = remoteCwd, allowFailure = false, printStdout = true, bootstrapEnv = true } = options
853
+ logProcessing(`\n→ ${label}`)
854
+
855
+ let wrappedCommand = command
856
+ let execOptions = { cwd }
857
+
858
+ if (bootstrapEnv) {
859
+ const cwdForShell = escapeForDoubleQuotes(cwd)
860
+ wrappedCommand = `${profileBootstrap}; cd "${cwdForShell}" && ${command}`
861
+ execOptions = {}
862
+ }
863
+
864
+ const result = await ssh.execCommand(wrappedCommand, execOptions)
865
+
866
+ // Log all output to file
867
+ if (result.stdout && result.stdout.trim()) {
868
+ await writeToLogFile(rootDir, `[${label}] STDOUT:\n${result.stdout.trim()}`)
869
+ }
870
+
871
+ if (result.stderr && result.stderr.trim()) {
872
+ await writeToLogFile(rootDir, `[${label}] STDERR:\n${result.stderr.trim()}`)
873
+ }
874
+
875
+ // Only show errors in terminal
876
+ if (result.code !== 0) {
877
+ if (result.stdout && result.stdout.trim()) {
878
+ logError(`\n[${label}] Output:\n${result.stdout.trim()}`)
879
+ }
880
+
881
+ if (result.stderr && result.stderr.trim()) {
882
+ logError(`\n[${label}] Error:\n${result.stderr.trim()}`)
883
+ }
884
+ }
885
+
886
+ if (result.code !== 0 && !allowFailure) {
887
+ const stderr = result.stderr?.trim() ?? ''
888
+ if (/command not found/.test(stderr) || /is not recognized/.test(stderr)) {
889
+ throw new Error(
890
+ `Command failed: ${command}. Ensure the remote environment loads required tools for non-interactive shells (e.g. export PATH in profile scripts).`
891
+ )
892
+ }
893
+
894
+ throw new Error(`Command failed: ${command}`)
895
+ }
896
+
897
+ // Show success confirmation with command
898
+ if (result.code === 0) {
899
+ logSuccess(`✓ ${command}`)
900
+ }
901
+
902
+ return result
903
+ }
904
+
905
+ const laravelCheck = await ssh.execCommand(
906
+ 'if [ -f artisan ] && [ -f composer.json ] && grep -q "laravel/framework" composer.json; then echo "yes"; else echo "no"; fi',
907
+ { cwd: remoteCwd }
908
+ )
909
+ const isLaravel = laravelCheck.stdout.trim() === 'yes'
910
+
911
+ if (isLaravel) {
912
+ logSuccess('Laravel project detected.')
913
+ } else {
914
+ logWarning('Laravel project not detected; skipping Laravel-specific maintenance tasks.')
915
+ }
916
+
917
+ let changedFiles = []
918
+
919
+ if (snapshot && snapshot.changedFiles) {
920
+ changedFiles = snapshot.changedFiles
921
+ logProcessing('Resuming deployment with saved task snapshot.')
922
+ } else if (isLaravel) {
923
+ await executeRemote(`Fetch latest changes for ${config.branch}`, `git fetch origin ${config.branch}`)
924
+
925
+ const diffResult = await executeRemote(
926
+ 'Inspect pending changes',
927
+ `git diff --name-only HEAD..origin/${config.branch}`,
928
+ { printStdout: false }
929
+ )
930
+
931
+ changedFiles = diffResult.stdout
932
+ .split(/\r?\n/)
933
+ .map((line) => line.trim())
934
+ .filter(Boolean)
935
+
936
+ if (changedFiles.length > 0) {
937
+ const preview = changedFiles
938
+ .slice(0, 20)
939
+ .map((file) => ` - ${file}`)
940
+ .join('\n')
941
+
942
+ logProcessing(
943
+ `Detected ${changedFiles.length} changed file(s):\n${preview}${changedFiles.length > 20 ? '\n - ...' : ''
944
+ }`
945
+ )
946
+ } else {
947
+ logProcessing('No upstream file changes detected.')
948
+ }
949
+ }
950
+
951
+ const shouldRunComposer =
952
+ isLaravel &&
953
+ changedFiles.some(
954
+ (file) =>
955
+ file === 'composer.json' ||
956
+ file === 'composer.lock' ||
957
+ file.endsWith('/composer.json') ||
958
+ file.endsWith('/composer.lock')
959
+ )
960
+
961
+ const shouldRunMigrations =
962
+ isLaravel &&
963
+ changedFiles.some(
964
+ (file) => file.startsWith('database/migrations/') && file.endsWith('.php')
965
+ )
966
+
967
+ const hasPhpChanges = isLaravel && changedFiles.some((file) => file.endsWith('.php'))
968
+
969
+ const shouldRunNpmInstall =
970
+ isLaravel &&
971
+ changedFiles.some(
972
+ (file) =>
973
+ file === 'package.json' ||
974
+ file === 'package-lock.json' ||
975
+ file.endsWith('/package.json') ||
976
+ file.endsWith('/package-lock.json')
977
+ )
978
+
979
+ const hasFrontendChanges =
980
+ isLaravel &&
981
+ changedFiles.some((file) =>
982
+ ['.vue', '.css', '.scss', '.js', '.ts', '.tsx', '.less'].some((ext) =>
983
+ file.endsWith(ext)
984
+ )
985
+ )
986
+
987
+ const shouldRunBuild = isLaravel && (hasFrontendChanges || shouldRunNpmInstall)
988
+ const shouldClearCaches = hasPhpChanges
989
+ const shouldRestartQueues = hasPhpChanges
990
+
991
+ let horizonConfigured = false
992
+ if (shouldRestartQueues) {
993
+ const horizonCheck = await ssh.execCommand(
994
+ 'if [ -f config/horizon.php ]; then echo "yes"; else echo "no"; fi',
995
+ { cwd: remoteCwd }
996
+ )
997
+ horizonConfigured = horizonCheck.stdout.trim() === 'yes'
998
+ }
999
+
1000
+ const steps = [
1001
+ {
1002
+ label: `Pull latest changes for ${config.branch}`,
1003
+ command: `git pull origin ${config.branch}`
1004
+ }
1005
+ ]
1006
+
1007
+ if (shouldRunComposer) {
1008
+ steps.push({
1009
+ label: 'Update Composer dependencies',
1010
+ command: 'composer update --no-dev --no-interaction --prefer-dist'
1011
+ })
1012
+ }
1013
+
1014
+ if (shouldRunMigrations) {
1015
+ steps.push({
1016
+ label: 'Run database migrations',
1017
+ command: 'php artisan migrate --force'
1018
+ })
1019
+ }
1020
+
1021
+ if (shouldRunNpmInstall) {
1022
+ steps.push({
1023
+ label: 'Install Node dependencies',
1024
+ command: 'npm install'
1025
+ })
1026
+ }
1027
+
1028
+ if (shouldRunBuild) {
1029
+ steps.push({
1030
+ label: 'Compile frontend assets',
1031
+ command: 'npm run build'
1032
+ })
1033
+ }
1034
+
1035
+ if (shouldClearCaches) {
1036
+ steps.push({
1037
+ label: 'Clear Laravel caches',
1038
+ command: 'php artisan cache:clear && php artisan config:clear && php artisan view:clear'
1039
+ })
1040
+ }
1041
+
1042
+ if (shouldRestartQueues) {
1043
+ steps.push({
1044
+ label: horizonConfigured ? 'Restart Horizon workers' : 'Restart queue workers',
1045
+ command: horizonConfigured ? 'php artisan horizon:terminate' : 'php artisan queue:restart'
1046
+ })
1047
+ }
1048
+
1049
+ const usefulSteps = steps.length > 1
1050
+
1051
+ let pendingSnapshot
1052
+
1053
+ if (usefulSteps) {
1054
+ pendingSnapshot = snapshot ?? {
1055
+ serverName: config.serverName,
1056
+ branch: config.branch,
1057
+ projectPath: config.projectPath,
1058
+ sshUser: config.sshUser,
1059
+ createdAt: new Date().toISOString(),
1060
+ changedFiles,
1061
+ taskLabels: steps.map((step) => step.label)
1062
+ }
1063
+
1064
+ await savePendingTasksSnapshot(rootDir, pendingSnapshot)
1065
+
1066
+ const payload = Buffer.from(JSON.stringify(pendingSnapshot)).toString('base64')
1067
+ await executeRemote(
1068
+ 'Record pending deployment tasks',
1069
+ `mkdir -p .zephyr && echo '${payload}' | base64 --decode > .zephyr/${PENDING_TASKS_FILE}`,
1070
+ { printStdout: false }
1071
+ )
1072
+ }
1073
+
1074
+ if (steps.length === 1) {
1075
+ logProcessing('No additional maintenance tasks scheduled beyond git pull.')
1076
+ } else {
1077
+ const extraTasks = steps
1078
+ .slice(1)
1079
+ .map((step) => step.label)
1080
+ .join(', ')
1081
+
1082
+ logProcessing(`Additional tasks scheduled: ${extraTasks}`)
1083
+ }
1084
+
1085
+ let completed = false
1086
+
1087
+ try {
1088
+ for (const step of steps) {
1089
+ await executeRemote(step.label, step.command)
1090
+ }
1091
+
1092
+ completed = true
1093
+ } finally {
1094
+ if (usefulSteps && completed) {
1095
+ await executeRemote(
1096
+ 'Clear pending deployment snapshot',
1097
+ `rm -f .zephyr/${PENDING_TASKS_FILE}`,
1098
+ { printStdout: false, allowFailure: true }
1099
+ )
1100
+ await clearPendingTasksSnapshot(rootDir)
1101
+ }
1102
+ }
1103
+
1104
+ logSuccess('\nDeployment commands completed successfully.')
1105
+
1106
+ const logPath = await getLogFilePath(rootDir)
1107
+ logSuccess(`\nAll task output has been logged to: ${logPath}`)
1108
+ } catch (error) {
1109
+ const logPath = logFilePath || await getLogFilePath(rootDir).catch(() => null)
1110
+ if (logPath) {
1111
+ logError(`\nTask output has been logged to: ${logPath}`)
1112
+ }
1113
+ throw new Error(`Deployment failed: ${error.message}`)
1114
+ } finally {
1115
+ await closeLogFile()
1116
+ ssh.dispose()
1117
+ }
1118
+ }
1119
+
1120
+ async function promptServerDetails(existingServers = []) {
1121
+ const defaults = {
1122
+ serverName: existingServers.length === 0 ? 'home' : `server-${existingServers.length + 1}`,
1123
+ serverIp: '1.1.1.1'
1124
+ }
1125
+
1126
+ const answers = await runPrompt([
1127
+ {
1128
+ type: 'input',
1129
+ name: 'serverName',
1130
+ message: 'Server name',
1131
+ default: defaults.serverName
1132
+ },
1133
+ {
1134
+ type: 'input',
1135
+ name: 'serverIp',
1136
+ message: 'Server IP address',
1137
+ default: defaults.serverIp
1138
+ }
1139
+ ])
1140
+
1141
+ return {
1142
+ serverName: answers.serverName.trim() || defaults.serverName,
1143
+ serverIp: answers.serverIp.trim() || defaults.serverIp
1144
+ }
1145
+ }
1146
+
1147
+ async function selectServer(servers) {
1148
+ if (servers.length === 0) {
1149
+ logProcessing("No servers configured. Let's create one.")
1150
+ const server = await promptServerDetails()
1151
+ servers.push(server)
1152
+ await saveServers(servers)
1153
+ logSuccess('Saved server configuration to ~/.config/zephyr/servers.json')
1154
+ return server
1155
+ }
1156
+
1157
+ const choices = servers.map((server, index) => ({
1158
+ name: `${server.serverName} (${server.serverIp})`,
1159
+ value: index
1160
+ }))
1161
+
1162
+ choices.push(new inquirer.Separator(), {
1163
+ name: '➕ Register a new server',
1164
+ value: 'create'
1165
+ })
1166
+
1167
+ const { selection } = await runPrompt([
1168
+ {
1169
+ type: 'list',
1170
+ name: 'selection',
1171
+ message: 'Select server or register new',
1172
+ choices,
1173
+ default: 0
1174
+ }
1175
+ ])
1176
+
1177
+ if (selection === 'create') {
1178
+ const server = await promptServerDetails(servers)
1179
+ servers.push(server)
1180
+ await saveServers(servers)
1181
+ logSuccess('Appended server configuration to ~/.config/zephyr/servers.json')
1182
+ return server
1183
+ }
1184
+
1185
+ return servers[selection]
1186
+ }
1187
+
1188
+ async function promptAppDetails(currentDir, existing = {}) {
1189
+ const branches = await listGitBranches(currentDir)
1190
+ const defaultBranch = existing.branch || (branches.includes('master') ? 'master' : branches[0])
1191
+ const defaults = {
1192
+ projectPath: existing.projectPath || defaultProjectPath(currentDir),
1193
+ branch: defaultBranch
1194
+ }
1195
+
1196
+ const answers = await runPrompt([
1197
+ {
1198
+ type: 'input',
1199
+ name: 'projectPath',
1200
+ message: 'Remote project path',
1201
+ default: defaults.projectPath
1202
+ },
1203
+ {
1204
+ type: 'list',
1205
+ name: 'branchSelection',
1206
+ message: 'Branch to deploy',
1207
+ choices: [
1208
+ ...branches.map((branch) => ({ name: branch, value: branch })),
1209
+ new inquirer.Separator(),
1210
+ { name: 'Enter custom branch…', value: '__custom' }
1211
+ ],
1212
+ default: defaults.branch
1213
+ }
1214
+ ])
1215
+
1216
+ let branch = answers.branchSelection
1217
+
1218
+ if (branch === '__custom') {
1219
+ const { customBranch } = await runPrompt([
1220
+ {
1221
+ type: 'input',
1222
+ name: 'customBranch',
1223
+ message: 'Custom branch name',
1224
+ default: defaults.branch
1225
+ }
1226
+ ])
1227
+
1228
+ branch = customBranch.trim() || defaults.branch
1229
+ }
1230
+
1231
+ const sshDetails = await promptSshDetails(currentDir, existing)
1232
+
1233
+ return {
1234
+ projectPath: answers.projectPath.trim() || defaults.projectPath,
1235
+ branch,
1236
+ ...sshDetails
1237
+ }
1238
+ }
1239
+
1240
+ async function selectApp(projectConfig, server, currentDir) {
1241
+ const apps = projectConfig.apps ?? []
1242
+ const matches = apps
1243
+ .map((app, index) => ({ app, index }))
1244
+ .filter(({ app }) => app.serverName === server.serverName)
1245
+
1246
+ if (matches.length === 0) {
1247
+ logProcessing(`No applications configured for ${server.serverName}. Let's create one.`)
1248
+ const appDetails = await promptAppDetails(currentDir)
1249
+ const appConfig = {
1250
+ serverName: server.serverName,
1251
+ ...appDetails
1252
+ }
1253
+ projectConfig.apps.push(appConfig)
1254
+ await saveProjectConfig(currentDir, projectConfig)
1255
+ logSuccess('Saved deployment configuration to .zephyr/config.json')
1256
+ return appConfig
1257
+ }
1258
+
1259
+ const choices = matches.map(({ app, index }) => ({
1260
+ name: `${app.projectPath} (${app.branch})`,
1261
+ value: index
1262
+ }))
1263
+
1264
+ choices.push(new inquirer.Separator(), {
1265
+ name: '➕ Configure new application for this server',
1266
+ value: 'create'
1267
+ })
1268
+
1269
+ const { selection } = await runPrompt([
1270
+ {
1271
+ type: 'list',
1272
+ name: 'selection',
1273
+ message: `Select application for ${server.serverName}`,
1274
+ choices,
1275
+ default: 0
1276
+ }
1277
+ ])
1278
+
1279
+ if (selection === 'create') {
1280
+ const appDetails = await promptAppDetails(currentDir)
1281
+ const appConfig = {
1282
+ serverName: server.serverName,
1283
+ ...appDetails
1284
+ }
1285
+ projectConfig.apps.push(appConfig)
1286
+ await saveProjectConfig(currentDir, projectConfig)
1287
+ logSuccess('Appended deployment configuration to .zephyr/config.json')
1288
+ return appConfig
1289
+ }
1290
+
1291
+ const chosen = projectConfig.apps[selection]
1292
+ return chosen
1293
+ }
1294
+
1295
+ async function main() {
1296
+ const rootDir = process.cwd()
1297
+
1298
+ await ensureGitignoreEntry(rootDir)
1299
+ await ensureProjectReleaseScript(rootDir)
1300
+
1301
+ const servers = await loadServers()
1302
+ const server = await selectServer(servers)
1303
+ const projectConfig = await loadProjectConfig(rootDir)
1304
+ const appConfig = await selectApp(projectConfig, server, rootDir)
1305
+
1306
+ const updated = await ensureSshDetails(appConfig, rootDir)
1307
+
1308
+ if (updated) {
1309
+ await saveProjectConfig(rootDir, projectConfig)
1310
+ logSuccess('Updated .zephyr/config.json with SSH details.')
1311
+ }
1312
+
1313
+ const deploymentConfig = {
1314
+ serverName: server.serverName,
1315
+ serverIp: server.serverIp,
1316
+ projectPath: appConfig.projectPath,
1317
+ branch: appConfig.branch,
1318
+ sshUser: appConfig.sshUser,
1319
+ sshKey: appConfig.sshKey
1320
+ }
1321
+
1322
+ logProcessing('\nSelected deployment target:')
1323
+ console.log(JSON.stringify(deploymentConfig, null, 2))
1324
+
1325
+ const existingSnapshot = await loadPendingTasksSnapshot(rootDir)
1326
+ let snapshotToUse = null
1327
+
1328
+ if (existingSnapshot) {
1329
+ const matchesSelection =
1330
+ existingSnapshot.serverName === deploymentConfig.serverName &&
1331
+ existingSnapshot.branch === deploymentConfig.branch
1332
+
1333
+ const messageLines = [
1334
+ 'Pending deployment tasks were detected from a previous run.',
1335
+ `Server: ${existingSnapshot.serverName}`,
1336
+ `Branch: ${existingSnapshot.branch}`
1337
+ ]
1338
+
1339
+ if (existingSnapshot.taskLabels && existingSnapshot.taskLabels.length > 0) {
1340
+ messageLines.push(`Tasks: ${existingSnapshot.taskLabels.join(', ')}`)
1341
+ }
1342
+
1343
+ const { resumePendingTasks } = await runPrompt([
1344
+ {
1345
+ type: 'confirm',
1346
+ name: 'resumePendingTasks',
1347
+ message: `${messageLines.join(' | ')}. Resume using this plan?`,
1348
+ default: matchesSelection
1349
+ }
1350
+ ])
1351
+
1352
+ if (resumePendingTasks) {
1353
+ snapshotToUse = existingSnapshot
1354
+ logProcessing('Resuming deployment using saved task snapshot...')
1355
+ } else {
1356
+ await clearPendingTasksSnapshot(rootDir)
1357
+ logWarning('Discarded pending deployment snapshot.')
1358
+ }
1359
+ }
1360
+
1361
+ let lockAcquired = false
1362
+
1363
+ try {
1364
+ await acquireProjectLock(rootDir)
1365
+ lockAcquired = true
1366
+ await runRemoteTasks(deploymentConfig, { rootDir, snapshot: snapshotToUse })
1367
+ } finally {
1368
+ if (lockAcquired) {
1369
+ await releaseProjectLock(rootDir)
1370
+ }
1371
+ }
1372
+ }
1373
+
1374
+ export {
1375
+ ensureGitignoreEntry,
1376
+ ensureProjectReleaseScript,
1377
+ listSshKeys,
1378
+ resolveRemotePath,
1379
+ isPrivateKeyFile,
1380
+ runRemoteTasks,
1381
+ promptServerDetails,
1382
+ selectServer,
1383
+ promptAppDetails,
1384
+ selectApp,
1385
+ promptSshDetails,
1386
+ ensureSshDetails,
1387
+ ensureLocalRepositoryState,
1388
+ loadServers,
1389
+ loadProjectConfig,
1390
+ main
1391
+ }