@wyxos/zephyr 0.2.21 → 0.2.22
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 +144 -144
- package/bin/zephyr.mjs +29 -29
- package/package.json +58 -58
- package/src/config/project.mjs +118 -118
- package/src/config/servers.mjs +57 -57
- package/src/dependency-scanner.mjs +412 -433
- package/src/deploy/local-repo.mjs +215 -215
- package/src/deploy/locks.mjs +171 -171
- package/src/deploy/preflight.mjs +117 -117
- package/src/deploy/remote-exec.mjs +99 -99
- package/src/deploy/snapshots.mjs +35 -35
- package/src/index.mjs +91 -91
- package/src/main.mjs +677 -652
- package/src/project/bootstrap.mjs +147 -147
- package/src/runtime/local-command.mjs +18 -18
- package/src/runtime/prompt.mjs +14 -14
- package/src/runtime/ssh-client.mjs +14 -14
- package/src/ssh/index.mjs +8 -8
- package/src/ssh/keys.mjs +146 -146
- package/src/ssh/ssh.mjs +134 -134
- package/src/utils/command.mjs +92 -92
- package/src/utils/config-flow.mjs +284 -284
- package/src/utils/git.mjs +91 -91
- package/src/utils/id.mjs +6 -6
- package/src/utils/log-file.mjs +76 -76
- package/src/utils/output.mjs +29 -29
- package/src/utils/paths.mjs +28 -28
- package/src/utils/php-version.mjs +137 -0
- package/src/utils/remote-path.mjs +23 -23
- package/src/utils/task-planner.mjs +99 -96
- package/src/version-checker.mjs +162 -162
package/src/utils/git.mjs
CHANGED
|
@@ -1,91 +1,91 @@
|
|
|
1
|
-
import process from 'node:process'
|
|
2
|
-
import { runCommand, runCommandCapture } from './command.mjs'
|
|
3
|
-
|
|
4
|
-
export async function getCurrentBranch(rootDir = process.cwd(), { method = 'rev-parse' } = {}) {
|
|
5
|
-
if (method === 'show-current') {
|
|
6
|
-
const { stdout } = await runCommandCapture('git', ['branch', '--show-current'], { cwd: rootDir })
|
|
7
|
-
return stdout.trim() || null
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
const { stdout } = await runCommandCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootDir })
|
|
11
|
-
return stdout.trim() || null
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export async function getUpstreamRef(rootDir = process.cwd()) {
|
|
15
|
-
try {
|
|
16
|
-
const { stdout } = await runCommandCapture(
|
|
17
|
-
'git',
|
|
18
|
-
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
|
|
19
|
-
{ cwd: rootDir }
|
|
20
|
-
)
|
|
21
|
-
|
|
22
|
-
return stdout.trim() || null
|
|
23
|
-
} catch {
|
|
24
|
-
return null
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export async function ensureUpToDateWithUpstream({
|
|
29
|
-
branch,
|
|
30
|
-
upstreamRef,
|
|
31
|
-
rootDir = process.cwd(),
|
|
32
|
-
logStep = null,
|
|
33
|
-
logWarning = null
|
|
34
|
-
}) {
|
|
35
|
-
if (!upstreamRef) {
|
|
36
|
-
logWarning?.(`Branch ${branch} has no upstream configured; skipping ahead/behind checks.`)
|
|
37
|
-
return
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const [remoteName, ...branchParts] = upstreamRef.split('/')
|
|
41
|
-
const remoteBranch = branchParts.join('/')
|
|
42
|
-
|
|
43
|
-
if (remoteName && remoteBranch) {
|
|
44
|
-
logStep?.(`Fetching latest updates from ${remoteName}/${remoteBranch}...`)
|
|
45
|
-
try {
|
|
46
|
-
await runCommand('git', ['fetch', remoteName, remoteBranch], { cwd: rootDir, stdio: 'ignore' })
|
|
47
|
-
} catch (error) {
|
|
48
|
-
throw new Error(`Failed to fetch ${upstreamRef}: ${error.message}`)
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
const aheadResult = await runCommandCapture('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
|
|
53
|
-
cwd: rootDir
|
|
54
|
-
})
|
|
55
|
-
const behindResult = await runCommandCapture('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
|
|
56
|
-
cwd: rootDir
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
const ahead = Number.parseInt(aheadResult.stdout || '0', 10)
|
|
60
|
-
const behind = Number.parseInt(behindResult.stdout || '0', 10)
|
|
61
|
-
|
|
62
|
-
if (Number.isFinite(behind) && behind > 0) {
|
|
63
|
-
if (remoteName && remoteBranch) {
|
|
64
|
-
logStep?.(`Fast-forwarding ${branch} with ${upstreamRef}...`)
|
|
65
|
-
|
|
66
|
-
try {
|
|
67
|
-
await runCommand('git', ['pull', '--ff-only', remoteName, remoteBranch], { cwd: rootDir, stdio: 'ignore' })
|
|
68
|
-
} catch (error) {
|
|
69
|
-
throw new Error(
|
|
70
|
-
`Unable to fast-forward ${branch} with ${upstreamRef}. Resolve conflicts manually, then rerun.\n${error.message}`
|
|
71
|
-
)
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
await ensureUpToDateWithUpstream({ branch, upstreamRef, rootDir, logStep, logWarning })
|
|
75
|
-
return
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
throw new Error(
|
|
79
|
-
`Branch ${branch} is behind ${upstreamRef} by ${behind} commit${behind === 1 ? '' : 's'}. Pull or rebase first.`
|
|
80
|
-
)
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
if (Number.isFinite(ahead) && ahead > 0) {
|
|
84
|
-
logWarning?.(`Branch ${branch} is ahead of ${upstreamRef} by ${ahead} commit${ahead === 1 ? '' : 's'}.`)
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export async function pushQuiet(rootDir = process.cwd(), args = []) {
|
|
89
|
-
await runCommandCapture('git', ['push', ...args], { cwd: rootDir })
|
|
90
|
-
}
|
|
91
|
-
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
import { runCommand, runCommandCapture } from './command.mjs'
|
|
3
|
+
|
|
4
|
+
export async function getCurrentBranch(rootDir = process.cwd(), { method = 'rev-parse' } = {}) {
|
|
5
|
+
if (method === 'show-current') {
|
|
6
|
+
const { stdout } = await runCommandCapture('git', ['branch', '--show-current'], { cwd: rootDir })
|
|
7
|
+
return stdout.trim() || null
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { stdout } = await runCommandCapture('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: rootDir })
|
|
11
|
+
return stdout.trim() || null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function getUpstreamRef(rootDir = process.cwd()) {
|
|
15
|
+
try {
|
|
16
|
+
const { stdout } = await runCommandCapture(
|
|
17
|
+
'git',
|
|
18
|
+
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'],
|
|
19
|
+
{ cwd: rootDir }
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
return stdout.trim() || null
|
|
23
|
+
} catch {
|
|
24
|
+
return null
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function ensureUpToDateWithUpstream({
|
|
29
|
+
branch,
|
|
30
|
+
upstreamRef,
|
|
31
|
+
rootDir = process.cwd(),
|
|
32
|
+
logStep = null,
|
|
33
|
+
logWarning = null
|
|
34
|
+
}) {
|
|
35
|
+
if (!upstreamRef) {
|
|
36
|
+
logWarning?.(`Branch ${branch} has no upstream configured; skipping ahead/behind checks.`)
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const [remoteName, ...branchParts] = upstreamRef.split('/')
|
|
41
|
+
const remoteBranch = branchParts.join('/')
|
|
42
|
+
|
|
43
|
+
if (remoteName && remoteBranch) {
|
|
44
|
+
logStep?.(`Fetching latest updates from ${remoteName}/${remoteBranch}...`)
|
|
45
|
+
try {
|
|
46
|
+
await runCommand('git', ['fetch', remoteName, remoteBranch], { cwd: rootDir, stdio: 'ignore' })
|
|
47
|
+
} catch (error) {
|
|
48
|
+
throw new Error(`Failed to fetch ${upstreamRef}: ${error.message}`)
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const aheadResult = await runCommandCapture('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], {
|
|
53
|
+
cwd: rootDir
|
|
54
|
+
})
|
|
55
|
+
const behindResult = await runCommandCapture('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], {
|
|
56
|
+
cwd: rootDir
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const ahead = Number.parseInt(aheadResult.stdout || '0', 10)
|
|
60
|
+
const behind = Number.parseInt(behindResult.stdout || '0', 10)
|
|
61
|
+
|
|
62
|
+
if (Number.isFinite(behind) && behind > 0) {
|
|
63
|
+
if (remoteName && remoteBranch) {
|
|
64
|
+
logStep?.(`Fast-forwarding ${branch} with ${upstreamRef}...`)
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await runCommand('git', ['pull', '--ff-only', remoteName, remoteBranch], { cwd: rootDir, stdio: 'ignore' })
|
|
68
|
+
} catch (error) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`Unable to fast-forward ${branch} with ${upstreamRef}. Resolve conflicts manually, then rerun.\n${error.message}`
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await ensureUpToDateWithUpstream({ branch, upstreamRef, rootDir, logStep, logWarning })
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
throw new Error(
|
|
79
|
+
`Branch ${branch} is behind ${upstreamRef} by ${behind} commit${behind === 1 ? '' : 's'}. Pull or rebase first.`
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (Number.isFinite(ahead) && ahead > 0) {
|
|
84
|
+
logWarning?.(`Branch ${branch} is ahead of ${upstreamRef} by ${ahead} commit${ahead === 1 ? '' : 's'}.`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function pushQuiet(rootDir = process.cwd(), args = []) {
|
|
89
|
+
await runCommandCapture('git', ['push', ...args], { cwd: rootDir })
|
|
90
|
+
}
|
|
91
|
+
|
package/src/utils/id.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import crypto from 'node:crypto'
|
|
2
|
-
|
|
3
|
-
export function generateId() {
|
|
4
|
-
return crypto.randomBytes(8).toString('hex')
|
|
5
|
-
}
|
|
6
|
-
|
|
1
|
+
import crypto from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
export function generateId() {
|
|
4
|
+
return crypto.randomBytes(8).toString('hex')
|
|
5
|
+
}
|
|
6
|
+
|
package/src/utils/log-file.mjs
CHANGED
|
@@ -1,76 +1,76 @@
|
|
|
1
|
-
import fs from 'node:fs/promises'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
import { ensureDirectory, getProjectConfigDir } from './paths.mjs'
|
|
4
|
-
|
|
5
|
-
let logFilePath = null
|
|
6
|
-
|
|
7
|
-
export async function getLogFilePath(rootDir) {
|
|
8
|
-
if (logFilePath) {
|
|
9
|
-
return logFilePath
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
const configDir = getProjectConfigDir(rootDir)
|
|
13
|
-
await ensureDirectory(configDir)
|
|
14
|
-
|
|
15
|
-
const now = new Date()
|
|
16
|
-
const dateStr = now.toISOString().replace(/:/g, '-').replace(/\..+/, '')
|
|
17
|
-
logFilePath = path.join(configDir, `${dateStr}.log`)
|
|
18
|
-
|
|
19
|
-
return logFilePath
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export async function writeToLogFile(rootDir, message) {
|
|
23
|
-
const logPath = await getLogFilePath(rootDir)
|
|
24
|
-
const timestamp = new Date().toISOString()
|
|
25
|
-
await fs.appendFile(logPath, `${timestamp} - ${message}\n`)
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export async function closeLogFile() {
|
|
29
|
-
logFilePath = null
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function cleanupOldLogs(rootDir) {
|
|
33
|
-
const configDir = getProjectConfigDir(rootDir)
|
|
34
|
-
|
|
35
|
-
try {
|
|
36
|
-
const files = await fs.readdir(configDir)
|
|
37
|
-
const logFiles = files
|
|
38
|
-
.filter((file) => file.endsWith('.log'))
|
|
39
|
-
.map((file) => ({
|
|
40
|
-
name: file,
|
|
41
|
-
path: path.join(configDir, file)
|
|
42
|
-
}))
|
|
43
|
-
|
|
44
|
-
if (logFiles.length <= 3) {
|
|
45
|
-
return
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const filesWithStats = await Promise.all(
|
|
49
|
-
logFiles.map(async (file) => {
|
|
50
|
-
const stats = await fs.stat(file.path)
|
|
51
|
-
return {
|
|
52
|
-
...file,
|
|
53
|
-
mtime: stats.mtime
|
|
54
|
-
}
|
|
55
|
-
})
|
|
56
|
-
)
|
|
57
|
-
|
|
58
|
-
filesWithStats.sort((a, b) => b.mtime - a.mtime)
|
|
59
|
-
|
|
60
|
-
const filesToDelete = filesWithStats.slice(3)
|
|
61
|
-
|
|
62
|
-
for (const file of filesToDelete) {
|
|
63
|
-
try {
|
|
64
|
-
await fs.unlink(file.path)
|
|
65
|
-
} catch (_error) {
|
|
66
|
-
// Ignore errors when deleting old logs
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
} catch (error) {
|
|
70
|
-
// Ignore errors during log cleanup
|
|
71
|
-
if (error.code !== 'ENOENT') {
|
|
72
|
-
// Only log if it's not a "directory doesn't exist" error
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { ensureDirectory, getProjectConfigDir } from './paths.mjs'
|
|
4
|
+
|
|
5
|
+
let logFilePath = null
|
|
6
|
+
|
|
7
|
+
export async function getLogFilePath(rootDir) {
|
|
8
|
+
if (logFilePath) {
|
|
9
|
+
return logFilePath
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const configDir = getProjectConfigDir(rootDir)
|
|
13
|
+
await ensureDirectory(configDir)
|
|
14
|
+
|
|
15
|
+
const now = new Date()
|
|
16
|
+
const dateStr = now.toISOString().replace(/:/g, '-').replace(/\..+/, '')
|
|
17
|
+
logFilePath = path.join(configDir, `${dateStr}.log`)
|
|
18
|
+
|
|
19
|
+
return logFilePath
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function writeToLogFile(rootDir, message) {
|
|
23
|
+
const logPath = await getLogFilePath(rootDir)
|
|
24
|
+
const timestamp = new Date().toISOString()
|
|
25
|
+
await fs.appendFile(logPath, `${timestamp} - ${message}\n`)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function closeLogFile() {
|
|
29
|
+
logFilePath = null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function cleanupOldLogs(rootDir) {
|
|
33
|
+
const configDir = getProjectConfigDir(rootDir)
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const files = await fs.readdir(configDir)
|
|
37
|
+
const logFiles = files
|
|
38
|
+
.filter((file) => file.endsWith('.log'))
|
|
39
|
+
.map((file) => ({
|
|
40
|
+
name: file,
|
|
41
|
+
path: path.join(configDir, file)
|
|
42
|
+
}))
|
|
43
|
+
|
|
44
|
+
if (logFiles.length <= 3) {
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const filesWithStats = await Promise.all(
|
|
49
|
+
logFiles.map(async (file) => {
|
|
50
|
+
const stats = await fs.stat(file.path)
|
|
51
|
+
return {
|
|
52
|
+
...file,
|
|
53
|
+
mtime: stats.mtime
|
|
54
|
+
}
|
|
55
|
+
})
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
filesWithStats.sort((a, b) => b.mtime - a.mtime)
|
|
59
|
+
|
|
60
|
+
const filesToDelete = filesWithStats.slice(3)
|
|
61
|
+
|
|
62
|
+
for (const file of filesToDelete) {
|
|
63
|
+
try {
|
|
64
|
+
await fs.unlink(file.path)
|
|
65
|
+
} catch (_error) {
|
|
66
|
+
// Ignore errors when deleting old logs
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch (error) {
|
|
70
|
+
// Ignore errors during log cleanup
|
|
71
|
+
if (error.code !== 'ENOENT') {
|
|
72
|
+
// Only log if it's not a "directory doesn't exist" error
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
package/src/utils/output.mjs
CHANGED
|
@@ -1,29 +1,29 @@
|
|
|
1
|
-
import process from 'node:process'
|
|
2
|
-
|
|
3
|
-
export function writeStdoutLine(message = '') {
|
|
4
|
-
const text = message == null ? '' : String(message)
|
|
5
|
-
process.stdout.write(`${text}\n`)
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export function writeStderrLine(message = '') {
|
|
9
|
-
const text = message == null ? '' : String(message)
|
|
10
|
-
process.stderr.write(`${text}\n`)
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function writeStderr(message = '') {
|
|
14
|
-
const text = message == null ? '' : String(message)
|
|
15
|
-
process.stderr.write(text)
|
|
16
|
-
if (text && !text.endsWith('\n')) {
|
|
17
|
-
process.stderr.write('\n')
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function createChalkLogger(chalk) {
|
|
22
|
-
return {
|
|
23
|
-
logProcessing: (message = '') => writeStdoutLine(chalk.yellow(message)),
|
|
24
|
-
logSuccess: (message = '') => writeStdoutLine(chalk.green(message)),
|
|
25
|
-
logWarning: (message = '') => writeStderrLine(chalk.yellow(message)),
|
|
26
|
-
logError: (message = '') => writeStderrLine(chalk.red(message))
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
|
|
3
|
+
export function writeStdoutLine(message = '') {
|
|
4
|
+
const text = message == null ? '' : String(message)
|
|
5
|
+
process.stdout.write(`${text}\n`)
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function writeStderrLine(message = '') {
|
|
9
|
+
const text = message == null ? '' : String(message)
|
|
10
|
+
process.stderr.write(`${text}\n`)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function writeStderr(message = '') {
|
|
14
|
+
const text = message == null ? '' : String(message)
|
|
15
|
+
process.stderr.write(text)
|
|
16
|
+
if (text && !text.endsWith('\n')) {
|
|
17
|
+
process.stderr.write('\n')
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createChalkLogger(chalk) {
|
|
22
|
+
return {
|
|
23
|
+
logProcessing: (message = '') => writeStdoutLine(chalk.yellow(message)),
|
|
24
|
+
logSuccess: (message = '') => writeStdoutLine(chalk.green(message)),
|
|
25
|
+
logWarning: (message = '') => writeStderrLine(chalk.yellow(message)),
|
|
26
|
+
logError: (message = '') => writeStderrLine(chalk.red(message))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
package/src/utils/paths.mjs
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
|
-
import fs from 'node:fs/promises'
|
|
2
|
-
import path from 'node:path'
|
|
3
|
-
|
|
4
|
-
export const PROJECT_CONFIG_DIR = '.zephyr'
|
|
5
|
-
export const PROJECT_CONFIG_FILE = 'config.json'
|
|
6
|
-
export const PROJECT_LOCK_FILE = 'deploy.lock'
|
|
7
|
-
export const PENDING_TASKS_FILE = 'pending-tasks.json'
|
|
8
|
-
|
|
9
|
-
export async function ensureDirectory(dirPath) {
|
|
10
|
-
await fs.mkdir(dirPath, { recursive: true })
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function getProjectConfigDir(rootDir) {
|
|
14
|
-
return path.join(rootDir, PROJECT_CONFIG_DIR)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function getProjectConfigPath(rootDir) {
|
|
18
|
-
return path.join(rootDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE)
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function getPendingTasksPath(rootDir) {
|
|
22
|
-
return path.join(getProjectConfigDir(rootDir), PENDING_TASKS_FILE)
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function getLockFilePath(rootDir) {
|
|
26
|
-
return path.join(getProjectConfigDir(rootDir), PROJECT_LOCK_FILE)
|
|
27
|
-
}
|
|
28
|
-
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
export const PROJECT_CONFIG_DIR = '.zephyr'
|
|
5
|
+
export const PROJECT_CONFIG_FILE = 'config.json'
|
|
6
|
+
export const PROJECT_LOCK_FILE = 'deploy.lock'
|
|
7
|
+
export const PENDING_TASKS_FILE = 'pending-tasks.json'
|
|
8
|
+
|
|
9
|
+
export async function ensureDirectory(dirPath) {
|
|
10
|
+
await fs.mkdir(dirPath, { recursive: true })
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function getProjectConfigDir(rootDir) {
|
|
14
|
+
return path.join(rootDir, PROJECT_CONFIG_DIR)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function getProjectConfigPath(rootDir) {
|
|
18
|
+
return path.join(rootDir, PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILE)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getPendingTasksPath(rootDir) {
|
|
22
|
+
return path.join(getProjectConfigDir(rootDir), PENDING_TASKS_FILE)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function getLockFilePath(rootDir) {
|
|
26
|
+
return path.join(getProjectConfigDir(rootDir), PROJECT_LOCK_FILE)
|
|
27
|
+
}
|
|
28
|
+
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import semver from 'semver'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Extracts the minimum PHP version requirement from a composer.json object
|
|
7
|
+
* @param {object} composer - Parsed composer.json object
|
|
8
|
+
* @returns {string|null} - PHP version requirement (e.g., "8.4.0") or null
|
|
9
|
+
*/
|
|
10
|
+
export function parsePhpVersionRequirement(composer) {
|
|
11
|
+
const phpRequirement = composer?.require?.php || composer?.['require-dev']?.php
|
|
12
|
+
if (!phpRequirement) {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Parse version constraint (e.g., "^8.4", ">=8.4.0", "8.4.*", "~8.4.0")
|
|
17
|
+
// Extract the minimum version needed
|
|
18
|
+
const versionMatch = phpRequirement.match(/(\d+)\.(\d+)(?:\.(\d+))?/)
|
|
19
|
+
if (!versionMatch) {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const major = versionMatch[1]
|
|
24
|
+
const minor = versionMatch[2]
|
|
25
|
+
const patch = versionMatch[3] || '0'
|
|
26
|
+
|
|
27
|
+
const versionStr = `${major}.${minor}.${patch}`
|
|
28
|
+
|
|
29
|
+
// Normalize to semver format
|
|
30
|
+
if (semver.valid(versionStr)) {
|
|
31
|
+
return versionStr
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Try to coerce to valid semver
|
|
35
|
+
const coerced = semver.coerce(versionStr)
|
|
36
|
+
if (coerced) {
|
|
37
|
+
return coerced.version
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Extracts the minimum PHP version requirement from composer.json file
|
|
45
|
+
* @param {string} rootDir - Project root directory
|
|
46
|
+
* @returns {Promise<string|null>} - PHP version requirement (e.g., "8.4.0") or null
|
|
47
|
+
*/
|
|
48
|
+
export async function getPhpVersionRequirement(rootDir) {
|
|
49
|
+
try {
|
|
50
|
+
const composerPath = path.join(rootDir, 'composer.json')
|
|
51
|
+
const raw = await fs.readFile(composerPath, 'utf8')
|
|
52
|
+
const composer = JSON.parse(raw)
|
|
53
|
+
return parsePhpVersionRequirement(composer)
|
|
54
|
+
} catch {
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Finds the appropriate PHP binary command for a given version
|
|
61
|
+
* Tries common patterns: php8.4, php8.3, etc.
|
|
62
|
+
* @param {object} ssh - SSH client instance
|
|
63
|
+
* @param {string} remoteCwd - Remote working directory
|
|
64
|
+
* @param {string} requiredVersion - Required PHP version (e.g., "8.4.0")
|
|
65
|
+
* @returns {Promise<string>} - PHP command prefix (e.g., "php8.4" or "php")
|
|
66
|
+
*/
|
|
67
|
+
export async function findPhpBinary(ssh, remoteCwd, requiredVersion) {
|
|
68
|
+
if (!requiredVersion) {
|
|
69
|
+
return 'php'
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Extract major.minor version (e.g., "8.4" from "8.4.0")
|
|
73
|
+
const majorMinor = semver.major(requiredVersion) + '.' + semver.minor(requiredVersion)
|
|
74
|
+
const versionedPhp = `php${majorMinor.replace('.', '')}` // e.g., "php84"
|
|
75
|
+
|
|
76
|
+
// Try versioned PHP binary first (e.g., php8.4, php84)
|
|
77
|
+
const candidates = [
|
|
78
|
+
`php${majorMinor}`, // php8.4
|
|
79
|
+
versionedPhp, // php84
|
|
80
|
+
'php' // fallback
|
|
81
|
+
]
|
|
82
|
+
|
|
83
|
+
for (const candidate of candidates) {
|
|
84
|
+
try {
|
|
85
|
+
const result = await ssh.execCommand(`command -v ${candidate}`, { cwd: remoteCwd })
|
|
86
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
87
|
+
// Verify it's actually the right version
|
|
88
|
+
const versionCheck = await ssh.execCommand(`${candidate} -r "echo PHP_VERSION;"`, { cwd: remoteCwd })
|
|
89
|
+
if (versionCheck.code === 0) {
|
|
90
|
+
const actualVersion = versionCheck.stdout.trim()
|
|
91
|
+
// Normalize version and check if it satisfies the requirement
|
|
92
|
+
const normalizedVersion = semver.coerce(actualVersion)
|
|
93
|
+
if (normalizedVersion && semver.gte(normalizedVersion, semver.coerce(requiredVersion))) {
|
|
94
|
+
return candidate
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} catch {
|
|
99
|
+
// Continue to next candidate
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Fallback: try to use default php and check version
|
|
104
|
+
try {
|
|
105
|
+
const versionCheck = await ssh.execCommand('php -r "echo PHP_VERSION;"', { cwd: remoteCwd })
|
|
106
|
+
if (versionCheck.code === 0) {
|
|
107
|
+
const actualVersion = versionCheck.stdout.trim()
|
|
108
|
+
const normalizedVersion = semver.coerce(actualVersion)
|
|
109
|
+
if (normalizedVersion && semver.gte(normalizedVersion, semver.coerce(requiredVersion))) {
|
|
110
|
+
return 'php'
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// Ignore
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// If we can't find a suitable version, return the versioned command anyway
|
|
118
|
+
// The error will be clearer when the command fails
|
|
119
|
+
return `php${majorMinor}`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Gets the PHP command prefix for use in commands
|
|
124
|
+
* @param {object} ssh - SSH client instance
|
|
125
|
+
* @param {string} remoteCwd - Remote working directory
|
|
126
|
+
* @param {string} rootDir - Local project root directory
|
|
127
|
+
* @returns {Promise<string>} - PHP command prefix (e.g., "php8.4" or "php")
|
|
128
|
+
*/
|
|
129
|
+
export async function getPhpCommandPrefix(ssh, remoteCwd, rootDir) {
|
|
130
|
+
const requiredVersion = await getPhpVersionRequirement(rootDir)
|
|
131
|
+
|
|
132
|
+
if (!requiredVersion) {
|
|
133
|
+
return 'php'
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return await findPhpBinary(ssh, remoteCwd, requiredVersion)
|
|
137
|
+
}
|
|
@@ -1,23 +1,23 @@
|
|
|
1
|
-
export function resolveRemotePath(projectPath, remoteHome) {
|
|
2
|
-
if (!projectPath) {
|
|
3
|
-
return projectPath
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
const sanitizedHome = remoteHome.replace(/\/+$/, '')
|
|
7
|
-
|
|
8
|
-
if (projectPath === '~') {
|
|
9
|
-
return sanitizedHome
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
if (projectPath.startsWith('~/')) {
|
|
13
|
-
const remainder = projectPath.slice(2)
|
|
14
|
-
return remainder ? `${sanitizedHome}/${remainder}` : sanitizedHome
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
if (projectPath.startsWith('/')) {
|
|
18
|
-
return projectPath
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
return `${sanitizedHome}/${projectPath}`
|
|
22
|
-
}
|
|
23
|
-
|
|
1
|
+
export function resolveRemotePath(projectPath, remoteHome) {
|
|
2
|
+
if (!projectPath) {
|
|
3
|
+
return projectPath
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const sanitizedHome = remoteHome.replace(/\/+$/, '')
|
|
7
|
+
|
|
8
|
+
if (projectPath === '~') {
|
|
9
|
+
return sanitizedHome
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (projectPath.startsWith('~/')) {
|
|
13
|
+
const remainder = projectPath.slice(2)
|
|
14
|
+
return remainder ? `${sanitizedHome}/${remainder}` : sanitizedHome
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (projectPath.startsWith('/')) {
|
|
18
|
+
return projectPath
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return `${sanitizedHome}/${projectPath}`
|
|
22
|
+
}
|
|
23
|
+
|