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