@wyxos/zephyr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # @wyxos/zephyr
2
+
3
+ A streamlined deployment tool for web applications with intelligent Laravel project detection.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install -g @wyxos/zephyr
9
+ ```
10
+
11
+ Or run directly with npx:
12
+
13
+ ```bash
14
+ npx @wyxos/zephyr
15
+ ```
16
+
17
+ ## Usage
18
+
19
+ Navigate to your project directory and run:
20
+
21
+ ```bash
22
+ zephyr
23
+ ```
24
+
25
+ Follow the interactive prompts to configure your deployment target:
26
+ - Server name and IP address
27
+ - Project path on the remote server
28
+ - Git branch to deploy
29
+ - SSH user and private key
30
+
31
+ Configuration is saved to `release.json` for future deployments.
32
+
33
+ ## Features
34
+
35
+ - Automated Git operations (branch switching, commits, pushes)
36
+ - SSH-based deployment to remote servers
37
+ - Laravel project detection with smart task execution
38
+ - Intelligent dependency management (Composer, npm)
39
+ - Database migrations when detected
40
+ - Frontend asset compilation
41
+ - Cache clearing and queue worker management
42
+ - SSH key validation and management
43
+
44
+ ## Smart Task Execution
45
+
46
+ Zephyr analyzes changed files and runs appropriate tasks:
47
+
48
+ - **Always**: `git pull origin <branch>`
49
+ - **Composer files changed**: `composer update`
50
+ - **Migration files added**: `php artisan migrate`
51
+ - **package.json changed**: `npm install`
52
+ - **Frontend files changed**: `npm run build`
53
+ - **PHP files changed**: Clear Laravel caches, restart queues
54
+
55
+ ## Configuration
56
+
57
+ Deployment targets are stored in `release.json`:
58
+
59
+ ```json
60
+ [
61
+ {
62
+ "serverName": "production",
63
+ "serverIp": "192.168.1.100",
64
+ "projectPath": "~/webapps/myapp",
65
+ "branch": "main",
66
+ "sshUser": "forge",
67
+ "sshKey": "~/.ssh/id_rsa"
68
+ }
69
+ ]
70
+ ```
71
+
72
+ ## Requirements
73
+
74
+ - Node.js 16+
75
+ - Git
76
+ - SSH access to target servers
package/bin/zephyr.mjs ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import { main } from '../src/index.mjs'
3
+
4
+ main().catch((error) => {
5
+ console.error(error.message)
6
+ process.exit(1)
7
+ })
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@wyxos/zephyr",
3
+ "version": "0.1.0",
4
+ "description": "A streamlined deployment tool for web applications with intelligent Laravel project detection",
5
+ "type": "module",
6
+ "main": "./src/index.mjs",
7
+ "bin": {
8
+ "zephyr": "./bin/zephyr.mjs"
9
+ },
10
+ "scripts": {
11
+ "test": "vitest"
12
+ },
13
+ "keywords": [
14
+ "deployment",
15
+ "laravel",
16
+ "ssh",
17
+ "automation",
18
+ "devops",
19
+ "git"
20
+ ],
21
+ "author": "wyxos",
22
+ "license": "MIT",
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/wyxos/zephyr.git"
26
+ },
27
+ "bugs": {
28
+ "url": "https://github.com/wyxos/zephyr/issues"
29
+ },
30
+ "homepage": "https://github.com/wyxos/zephyr#readme",
31
+ "engines": {
32
+ "node": ">=16.0.0"
33
+ },
34
+ "files": [
35
+ "bin/",
36
+ "src/",
37
+ "README.md"
38
+ ],
39
+ "dependencies": {
40
+ "chalk": "5.3.0",
41
+ "inquirer": "^9.2.12",
42
+ "node-ssh": "^13.1.0"
43
+ },
44
+ "devDependencies": {
45
+ "vitest": "^2.1.8"
46
+ }
47
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,781 @@
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 RELEASE_FILE = 'release.json'
10
+
11
+ const logProcessing = (message = '') => console.log(chalk.yellow(message))
12
+ const logSuccess = (message = '') => console.log(chalk.green(message))
13
+ const logWarning = (message = '') => console.warn(chalk.yellow(message))
14
+ const logError = (message = '') => console.error(chalk.red(message))
15
+
16
+ const createSshClient = () => {
17
+ if (typeof globalThis !== 'undefined' && globalThis.__zephyrSSHFactory) {
18
+ return globalThis.__zephyrSSHFactory()
19
+ }
20
+
21
+ return new NodeSSH()
22
+ }
23
+
24
+ const runPrompt = async (questions) => {
25
+ if (typeof globalThis !== 'undefined' && globalThis.__zephyrPrompt) {
26
+ return globalThis.__zephyrPrompt(questions)
27
+ }
28
+
29
+ return inquirer.prompt(questions)
30
+ }
31
+
32
+ async function runCommand(command, args, { silent = false, cwd } = {}) {
33
+ return new Promise((resolve, reject) => {
34
+ const child = spawn(command, args, {
35
+ stdio: silent ? 'ignore' : 'inherit',
36
+ cwd
37
+ })
38
+
39
+ child.on('error', reject)
40
+ child.on('close', (code) => {
41
+ if (code === 0) {
42
+ resolve()
43
+ } else {
44
+ const error = new Error(`${command} exited with code ${code}`)
45
+ error.exitCode = code
46
+ reject(error)
47
+ }
48
+ })
49
+ })
50
+ }
51
+
52
+ async function runCommandCapture(command, args, { cwd } = {}) {
53
+ return new Promise((resolve, reject) => {
54
+ let stdout = ''
55
+ let stderr = ''
56
+
57
+ const child = spawn(command, args, {
58
+ stdio: ['ignore', 'pipe', 'pipe'],
59
+ cwd
60
+ })
61
+
62
+ child.stdout.on('data', (chunk) => {
63
+ stdout += chunk
64
+ })
65
+
66
+ child.stderr.on('data', (chunk) => {
67
+ stderr += chunk
68
+ })
69
+
70
+ child.on('error', reject)
71
+ child.on('close', (code) => {
72
+ if (code === 0) {
73
+ resolve(stdout)
74
+ } else {
75
+ const error = new Error(`${command} exited with code ${code}: ${stderr.trim()}`)
76
+ error.exitCode = code
77
+ reject(error)
78
+ }
79
+ })
80
+ })
81
+ }
82
+
83
+ async function getCurrentBranch(rootDir) {
84
+ const output = await runCommandCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
85
+ cwd: rootDir
86
+ })
87
+
88
+ return output.trim()
89
+ }
90
+
91
+ async function getGitStatus(rootDir) {
92
+ const output = await runCommandCapture('git', ['status', '--porcelain'], {
93
+ cwd: rootDir
94
+ })
95
+
96
+ return output.trim()
97
+ }
98
+
99
+ async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd()) {
100
+ if (!targetBranch) {
101
+ throw new Error('Deployment branch is not defined in the release configuration.')
102
+ }
103
+
104
+ const currentBranch = await getCurrentBranch(rootDir)
105
+
106
+ if (!currentBranch) {
107
+ throw new Error('Unable to determine the current git branch. Ensure this is a git repository.')
108
+ }
109
+
110
+ const initialStatus = await getGitStatus(rootDir)
111
+ const hasPendingChanges = initialStatus.length > 0
112
+
113
+ if (currentBranch !== targetBranch) {
114
+ if (hasPendingChanges) {
115
+ throw new Error(
116
+ `Local repository has uncommitted changes on ${currentBranch}. Commit or stash them before switching to ${targetBranch}.`
117
+ )
118
+ }
119
+
120
+ logProcessing(`Switching local repository from ${currentBranch} to ${targetBranch}...`)
121
+ await runCommand('git', ['checkout', targetBranch], { cwd: rootDir })
122
+ logSuccess(`Checked out ${targetBranch} locally.`)
123
+ }
124
+
125
+ const statusAfterCheckout = currentBranch === targetBranch ? initialStatus : await getGitStatus(rootDir)
126
+
127
+ if (statusAfterCheckout.length === 0) {
128
+ logProcessing('Local repository is clean. Proceeding with deployment.')
129
+ return
130
+ }
131
+
132
+ logWarning(`Uncommitted changes detected on ${targetBranch}. A commit is required before deployment.`)
133
+
134
+ const { commitMessage } = await runPrompt([
135
+ {
136
+ type: 'input',
137
+ name: 'commitMessage',
138
+ message: 'Enter a commit message for pending changes before deployment',
139
+ validate: (value) => (value && value.trim().length > 0 ? true : 'Commit message cannot be empty.')
140
+ }
141
+ ])
142
+
143
+ const message = commitMessage.trim()
144
+
145
+ logProcessing('Committing local changes before deployment...')
146
+ await runCommand('git', ['add', '-A'], { cwd: rootDir })
147
+ await runCommand('git', ['commit', '-m', message], { cwd: rootDir })
148
+ await runCommand('git', ['push', 'origin', targetBranch], { cwd: rootDir })
149
+ logSuccess(`Committed and pushed changes to origin/${targetBranch}.`)
150
+
151
+ const finalStatus = await getGitStatus(rootDir)
152
+
153
+ if (finalStatus.length > 0) {
154
+ throw new Error('Local repository still has uncommitted changes after commit. Aborting deployment.')
155
+ }
156
+
157
+ logProcessing('Local repository is clean after committing pending changes.')
158
+ }
159
+
160
+ async function ensureGitignoreEntry(rootDir) {
161
+ const gitignorePath = path.join(rootDir, '.gitignore')
162
+ let existingContent = ''
163
+
164
+ try {
165
+ existingContent = await fs.readFile(gitignorePath, 'utf8')
166
+ } catch (error) {
167
+ if (error.code !== 'ENOENT') {
168
+ throw error
169
+ }
170
+ }
171
+
172
+ const hasEntry = existingContent
173
+ .split(/\r?\n/)
174
+ .some((line) => line.trim() === RELEASE_FILE)
175
+
176
+ if (hasEntry) {
177
+ return
178
+ }
179
+
180
+ const updatedContent = existingContent
181
+ ? `${existingContent.replace(/\s*$/, '')}\n${RELEASE_FILE}\n`
182
+ : `${RELEASE_FILE}\n`
183
+
184
+ await fs.writeFile(gitignorePath, updatedContent)
185
+ logSuccess('Added release.json to .gitignore')
186
+
187
+ let isGitRepo = false
188
+ try {
189
+ await runCommand('git', ['rev-parse', '--is-inside-work-tree'], {
190
+ silent: true,
191
+ cwd: rootDir
192
+ })
193
+ isGitRepo = true
194
+ } catch (error) {
195
+ logWarning('Not a git repository; skipping commit for .gitignore update.')
196
+ }
197
+
198
+ if (!isGitRepo) {
199
+ return
200
+ }
201
+
202
+ try {
203
+ await runCommand('git', ['add', '.gitignore'], { cwd: rootDir })
204
+ await runCommand('git', ['commit', '-m', 'chore: ignore release config'], { cwd: rootDir })
205
+ } catch (error) {
206
+ if (error.exitCode === 1) {
207
+ logWarning('Git commit skipped: nothing to commit or pre-commit hook prevented commit.')
208
+ } else {
209
+ throw error
210
+ }
211
+ }
212
+ }
213
+
214
+ async function loadReleases(filePath) {
215
+ try {
216
+ const raw = await fs.readFile(filePath, 'utf8')
217
+ const data = JSON.parse(raw)
218
+ return Array.isArray(data) ? data : []
219
+ } catch (error) {
220
+ if (error.code === 'ENOENT') {
221
+ return []
222
+ }
223
+
224
+ logWarning('Failed to read release.json, starting with an empty list.')
225
+ return []
226
+ }
227
+ }
228
+
229
+ async function saveReleases(filePath, releases) {
230
+ const payload = JSON.stringify(releases, null, 2)
231
+ await fs.writeFile(filePath, `${payload}\n`)
232
+ }
233
+
234
+ function defaultProjectPath(currentDir) {
235
+ return `~/webapps/${path.basename(currentDir)}`
236
+ }
237
+
238
+ async function listGitBranches(currentDir) {
239
+ try {
240
+ const output = await runCommandCapture(
241
+ 'git',
242
+ ['branch', '--format', '%(refname:short)'],
243
+ { cwd: currentDir }
244
+ )
245
+
246
+ const branches = output
247
+ .split(/\r?\n/)
248
+ .map((line) => line.trim())
249
+ .filter(Boolean)
250
+
251
+ return branches.length ? branches : ['master']
252
+ } catch (error) {
253
+ logWarning('Unable to read git branches; defaulting to master.')
254
+ return ['master']
255
+ }
256
+ }
257
+
258
+ async function listSshKeys() {
259
+ const sshDir = path.join(os.homedir(), '.ssh')
260
+
261
+ try {
262
+ const entries = await fs.readdir(sshDir, { withFileTypes: true })
263
+
264
+ const candidates = entries
265
+ .filter((entry) => entry.isFile())
266
+ .map((entry) => entry.name)
267
+ .filter((name) => {
268
+ if (!name) return false
269
+ if (name.startsWith('.')) return false
270
+ if (name.endsWith('.pub')) return false
271
+ if (name.startsWith('known_hosts')) return false
272
+ if (name === 'config') return false
273
+ return name.trim().length > 0
274
+ })
275
+
276
+ const keys = []
277
+
278
+ for (const name of candidates) {
279
+ const filePath = path.join(sshDir, name)
280
+ if (await isPrivateKeyFile(filePath)) {
281
+ keys.push(name)
282
+ }
283
+ }
284
+
285
+ return {
286
+ sshDir,
287
+ keys
288
+ }
289
+ } catch (error) {
290
+ if (error.code === 'ENOENT') {
291
+ return {
292
+ sshDir,
293
+ keys: []
294
+ }
295
+ }
296
+
297
+ throw error
298
+ }
299
+ }
300
+
301
+ async function isPrivateKeyFile(filePath) {
302
+ try {
303
+ const content = await fs.readFile(filePath, 'utf8')
304
+ return /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(content)
305
+ } catch (error) {
306
+ return false
307
+ }
308
+ }
309
+
310
+ async function promptSshDetails(currentDir, existing = {}) {
311
+ const { sshDir, keys: sshKeys } = await listSshKeys()
312
+ const defaultUser = existing.sshUser || os.userInfo().username
313
+ const fallbackKey = path.join(sshDir, 'id_rsa')
314
+ const preselectedKey = existing.sshKey || (sshKeys.length ? path.join(sshDir, sshKeys[0]) : fallbackKey)
315
+
316
+ const sshKeyPrompt = sshKeys.length
317
+ ? {
318
+ type: 'list',
319
+ name: 'sshKeySelection',
320
+ message: 'SSH key',
321
+ choices: [
322
+ ...sshKeys.map((key) => ({ name: key, value: path.join(sshDir, key) })),
323
+ new inquirer.Separator(),
324
+ { name: 'Enter custom SSH key path…', value: '__custom' }
325
+ ],
326
+ default: preselectedKey
327
+ }
328
+ : {
329
+ type: 'input',
330
+ name: 'sshKeySelection',
331
+ message: 'SSH key path',
332
+ default: preselectedKey
333
+ }
334
+
335
+ const answers = await runPrompt([
336
+ {
337
+ type: 'input',
338
+ name: 'sshUser',
339
+ message: 'SSH user',
340
+ default: defaultUser
341
+ },
342
+ sshKeyPrompt
343
+ ])
344
+
345
+ let sshKey = answers.sshKeySelection
346
+
347
+ if (sshKey === '__custom') {
348
+ const { customSshKey } = await runPrompt([
349
+ {
350
+ type: 'input',
351
+ name: 'customSshKey',
352
+ message: 'SSH key path',
353
+ default: preselectedKey
354
+ }
355
+ ])
356
+
357
+ sshKey = customSshKey.trim() || preselectedKey
358
+ }
359
+
360
+ return {
361
+ sshUser: answers.sshUser.trim() || defaultUser,
362
+ sshKey: sshKey.trim() || preselectedKey
363
+ }
364
+ }
365
+
366
+ async function ensureSshDetails(config, currentDir) {
367
+ if (config.sshUser && config.sshKey) {
368
+ return false
369
+ }
370
+
371
+ logProcessing('SSH details missing. Please provide them now.')
372
+ const details = await promptSshDetails(currentDir, config)
373
+ Object.assign(config, details)
374
+ return true
375
+ }
376
+
377
+ function expandHomePath(targetPath) {
378
+ if (!targetPath) {
379
+ return targetPath
380
+ }
381
+
382
+ if (targetPath.startsWith('~')) {
383
+ return path.join(os.homedir(), targetPath.slice(1))
384
+ }
385
+
386
+ return targetPath
387
+ }
388
+
389
+ async function resolveSshKeyPath(targetPath) {
390
+ const expanded = expandHomePath(targetPath)
391
+
392
+ try {
393
+ await fs.access(expanded)
394
+ } catch (error) {
395
+ throw new Error(`SSH key not accessible at ${expanded}`)
396
+ }
397
+
398
+ return expanded
399
+ }
400
+
401
+ function resolveRemotePath(projectPath, remoteHome) {
402
+ if (!projectPath) {
403
+ return projectPath
404
+ }
405
+
406
+ const sanitizedHome = remoteHome.replace(/\/+$/, '')
407
+
408
+ if (projectPath === '~') {
409
+ return sanitizedHome
410
+ }
411
+
412
+ if (projectPath.startsWith('~/')) {
413
+ const remainder = projectPath.slice(2)
414
+ return remainder ? `${sanitizedHome}/${remainder}` : sanitizedHome
415
+ }
416
+
417
+ if (projectPath.startsWith('/')) {
418
+ return projectPath
419
+ }
420
+
421
+ return `${sanitizedHome}/${projectPath}`
422
+ }
423
+
424
+ async function runRemoteTasks(config) {
425
+ await ensureLocalRepositoryState(config.branch, process.cwd())
426
+
427
+ const ssh = createSshClient()
428
+ const sshUser = config.sshUser || os.userInfo().username
429
+ const privateKeyPath = await resolveSshKeyPath(config.sshKey)
430
+ const privateKey = await fs.readFile(privateKeyPath, 'utf8')
431
+
432
+ logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
433
+
434
+ try {
435
+ await ssh.connect({
436
+ host: config.serverIp,
437
+ username: sshUser,
438
+ privateKey
439
+ })
440
+
441
+ const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
442
+ const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
443
+ const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
444
+
445
+ logProcessing(`Connection established. Running deployment commands in ${remoteCwd}...`)
446
+
447
+ const executeRemote = async (label, command, options = {}) => {
448
+ const { cwd = remoteCwd, allowFailure = false, printStdout = true } = options
449
+ logProcessing(`\n→ ${label}`)
450
+ const result = await ssh.execCommand(command, { cwd })
451
+
452
+ if (printStdout && result.stdout && result.stdout.trim()) {
453
+ console.log(result.stdout.trim())
454
+ }
455
+
456
+ if (result.stderr && result.stderr.trim()) {
457
+ if (result.code === 0) {
458
+ logWarning(result.stderr.trim())
459
+ } else {
460
+ logError(result.stderr.trim())
461
+ }
462
+ }
463
+
464
+ if (result.code !== 0 && !allowFailure) {
465
+ throw new Error(`Command failed: ${command}`)
466
+ }
467
+
468
+ return result
469
+ }
470
+
471
+ const laravelCheck = await ssh.execCommand(
472
+ 'if [ -f artisan ] && [ -f composer.json ] && grep -q "laravel/framework" composer.json; then echo "yes"; else echo "no"; fi',
473
+ { cwd: remoteCwd }
474
+ )
475
+ const isLaravel = laravelCheck.stdout.trim() === 'yes'
476
+
477
+ if (isLaravel) {
478
+ logSuccess('Laravel project detected.')
479
+ } else {
480
+ logWarning('Laravel project not detected; skipping Laravel-specific maintenance tasks.')
481
+ }
482
+
483
+ let changedFiles = []
484
+
485
+ if (isLaravel) {
486
+ await executeRemote(`Fetch latest changes for ${config.branch}`, `git fetch origin ${config.branch}`)
487
+
488
+ const diffResult = await executeRemote(
489
+ 'Inspect pending changes',
490
+ `git diff --name-only HEAD..origin/${config.branch}`,
491
+ { printStdout: false }
492
+ )
493
+
494
+ changedFiles = diffResult.stdout
495
+ .split(/\r?\n/)
496
+ .map((line) => line.trim())
497
+ .filter(Boolean)
498
+
499
+ if (changedFiles.length > 0) {
500
+ const preview = changedFiles
501
+ .slice(0, 20)
502
+ .map((file) => ` - ${file}`)
503
+ .join('\n')
504
+
505
+ logProcessing(
506
+ `Detected ${changedFiles.length} changed file(s):\n${preview}${
507
+ changedFiles.length > 20 ? '\n - ...' : ''
508
+ }`
509
+ )
510
+ } else {
511
+ logProcessing('No upstream file changes detected.')
512
+ }
513
+ }
514
+
515
+ const shouldRunComposer =
516
+ isLaravel &&
517
+ changedFiles.some(
518
+ (file) =>
519
+ file === 'composer.json' ||
520
+ file === 'composer.lock' ||
521
+ file.endsWith('/composer.json') ||
522
+ file.endsWith('/composer.lock')
523
+ )
524
+
525
+ const shouldRunMigrations =
526
+ isLaravel &&
527
+ changedFiles.some(
528
+ (file) => file.startsWith('database/migrations/') && file.endsWith('.php')
529
+ )
530
+
531
+ const hasPhpChanges = isLaravel && changedFiles.some((file) => file.endsWith('.php'))
532
+
533
+ const shouldRunNpmInstall =
534
+ isLaravel &&
535
+ changedFiles.some(
536
+ (file) =>
537
+ file === 'package.json' ||
538
+ file === 'package-lock.json' ||
539
+ file.endsWith('/package.json') ||
540
+ file.endsWith('/package-lock.json')
541
+ )
542
+
543
+ const hasFrontendChanges =
544
+ isLaravel &&
545
+ changedFiles.some((file) =>
546
+ ['.vue', '.css', '.scss', '.js', '.ts', '.tsx', '.less'].some((ext) =>
547
+ file.endsWith(ext)
548
+ )
549
+ )
550
+
551
+ const shouldRunBuild = isLaravel && (hasFrontendChanges || shouldRunNpmInstall)
552
+ const shouldClearCaches = hasPhpChanges
553
+ const shouldRestartQueues = hasPhpChanges
554
+
555
+ let horizonConfigured = false
556
+ if (shouldRestartQueues) {
557
+ const horizonCheck = await ssh.execCommand(
558
+ 'if [ -f config/horizon.php ]; then echo "yes"; else echo "no"; fi',
559
+ { cwd: remoteCwd }
560
+ )
561
+ horizonConfigured = horizonCheck.stdout.trim() === 'yes'
562
+ }
563
+
564
+ const steps = [
565
+ {
566
+ label: `Pull latest changes for ${config.branch}`,
567
+ command: `git pull origin ${config.branch}`
568
+ }
569
+ ]
570
+
571
+ if (shouldRunComposer) {
572
+ steps.push({
573
+ label: 'Update Composer dependencies',
574
+ command: 'composer update --no-dev --no-interaction --prefer-dist'
575
+ })
576
+ }
577
+
578
+ if (shouldRunMigrations) {
579
+ steps.push({
580
+ label: 'Run database migrations',
581
+ command: 'php artisan migrate --force'
582
+ })
583
+ }
584
+
585
+ if (shouldRunNpmInstall) {
586
+ steps.push({
587
+ label: 'Install Node dependencies',
588
+ command: 'npm install'
589
+ })
590
+ }
591
+
592
+ if (shouldRunBuild) {
593
+ steps.push({
594
+ label: 'Compile frontend assets',
595
+ command: 'npm run build'
596
+ })
597
+ }
598
+
599
+ if (shouldClearCaches) {
600
+ steps.push({
601
+ label: 'Clear Laravel caches',
602
+ command: 'php artisan cache:clear && php artisan config:clear && php artisan view:clear'
603
+ })
604
+ }
605
+
606
+ if (shouldRestartQueues) {
607
+ steps.push({
608
+ label: horizonConfigured ? 'Restart Horizon workers' : 'Restart queue workers',
609
+ command: horizonConfigured ? 'php artisan horizon:terminate' : 'php artisan queue:restart'
610
+ })
611
+ }
612
+
613
+ if (steps.length === 1) {
614
+ logProcessing('No additional maintenance tasks scheduled beyond git pull.')
615
+ } else {
616
+ const extraTasks = steps
617
+ .slice(1)
618
+ .map((step) => step.label)
619
+ .join(', ')
620
+
621
+ logProcessing(`Additional tasks scheduled: ${extraTasks}`)
622
+ }
623
+
624
+ for (const step of steps) {
625
+ await executeRemote(step.label, step.command)
626
+ }
627
+
628
+ logSuccess('\nDeployment commands completed successfully.')
629
+ } catch (error) {
630
+ throw new Error(`Deployment failed: ${error.message}`)
631
+ } finally {
632
+ ssh.dispose()
633
+ }
634
+ }
635
+
636
+ async function collectServerConfig(currentDir) {
637
+ const branches = await listGitBranches(currentDir)
638
+ const defaultBranch = branches.includes('master') ? 'master' : branches[0]
639
+ const defaults = {
640
+ serverName: 'home',
641
+ serverIp: '1.1.1.1',
642
+ projectPath: defaultProjectPath(currentDir),
643
+ branch: defaultBranch
644
+ }
645
+
646
+ const answers = await runPrompt([
647
+ {
648
+ type: 'input',
649
+ name: 'serverName',
650
+ message: 'Server name',
651
+ default: defaults.serverName
652
+ },
653
+ {
654
+ type: 'input',
655
+ name: 'serverIp',
656
+ message: 'Server IP',
657
+ default: defaults.serverIp
658
+ },
659
+ {
660
+ type: 'input',
661
+ name: 'projectPath',
662
+ message: 'Project path',
663
+ default: defaults.projectPath
664
+ },
665
+ {
666
+ type: 'list',
667
+ name: 'branchSelection',
668
+ message: 'Branch',
669
+ choices: [
670
+ ...branches.map((branch) => ({ name: branch, value: branch })),
671
+ new inquirer.Separator(),
672
+ { name: 'Enter custom branch…', value: '__custom' }
673
+ ],
674
+ default: defaults.branch
675
+ }
676
+ ])
677
+
678
+ let branch = answers.branchSelection
679
+
680
+ if (branch === '__custom') {
681
+ const { customBranch } = await inquirer.prompt([
682
+ {
683
+ type: 'input',
684
+ name: 'customBranch',
685
+ message: 'Custom branch name',
686
+ default: defaults.branch
687
+ }
688
+ ])
689
+
690
+ branch = customBranch.trim() || defaults.branch
691
+ }
692
+
693
+ const sshDetails = await promptSshDetails(currentDir)
694
+
695
+ return {
696
+ serverName: answers.serverName,
697
+ serverIp: answers.serverIp,
698
+ projectPath: answers.projectPath,
699
+ branch,
700
+ ...sshDetails
701
+ }
702
+ }
703
+
704
+ async function promptSelection(releases) {
705
+ const choices = releases.map((entry, index) => ({
706
+ name: `${entry.serverName} (${entry.serverIp})` || `Server ${index + 1}`,
707
+ value: index
708
+ }))
709
+
710
+ choices.push(new inquirer.Separator(), {
711
+ name: '➕ Create new deployment target',
712
+ value: 'create'
713
+ })
714
+
715
+ const { selection } = await runPrompt([
716
+ {
717
+ type: 'list',
718
+ name: 'selection',
719
+ message: 'Select server or create new',
720
+ choices,
721
+ default: 0
722
+ }
723
+ ])
724
+
725
+ return selection
726
+ }
727
+
728
+ async function main() {
729
+ const rootDir = process.cwd()
730
+ const releasePath = path.join(rootDir, RELEASE_FILE)
731
+
732
+ await ensureGitignoreEntry(rootDir)
733
+
734
+ const releases = await loadReleases(releasePath)
735
+
736
+ if (releases.length === 0) {
737
+ logProcessing("No deployment targets found. Let's create one.")
738
+ const config = await collectServerConfig(rootDir)
739
+ releases.push(config)
740
+ await saveReleases(releasePath, releases)
741
+ logSuccess('Saved deployment configuration to release.json')
742
+ await runRemoteTasks(config)
743
+ return
744
+ }
745
+
746
+ const selection = await promptSelection(releases)
747
+
748
+ if (selection === 'create') {
749
+ const config = await collectServerConfig(rootDir)
750
+ releases.push(config)
751
+ await saveReleases(releasePath, releases)
752
+ logSuccess('Appended new deployment configuration to release.json')
753
+ await runRemoteTasks(config)
754
+ return
755
+ }
756
+
757
+ const chosen = releases[selection]
758
+ const updated = await ensureSshDetails(chosen, rootDir)
759
+
760
+ if (updated) {
761
+ await saveReleases(releasePath, releases)
762
+ logSuccess('Updated release.json with SSH details.')
763
+ }
764
+ logProcessing('\nSelected deployment target:')
765
+ console.log(JSON.stringify(chosen, null, 2))
766
+
767
+ await runRemoteTasks(chosen)
768
+ }
769
+
770
+ export {
771
+ ensureGitignoreEntry,
772
+ listSshKeys,
773
+ resolveRemotePath,
774
+ isPrivateKeyFile,
775
+ runRemoteTasks,
776
+ collectServerConfig,
777
+ promptSshDetails,
778
+ ensureSshDetails,
779
+ ensureLocalRepositoryState,
780
+ main
781
+ }