@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 +76 -0
- package/bin/zephyr.mjs +7 -0
- package/package.json +47 -0
- package/src/index.mjs +781 -0
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
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
|
+
}
|