@wyxos/zephyr 0.2.17 → 0.2.19
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 +45 -5
- package/bin/zephyr.mjs +22 -7
- package/package.json +5 -4
- package/src/config/project.mjs +118 -0
- package/src/config/servers.mjs +57 -0
- package/src/dependency-scanner.mjs +7 -41
- package/src/deploy/local-repo.mjs +215 -0
- package/src/deploy/locks.mjs +171 -0
- package/src/deploy/preflight.mjs +117 -0
- package/src/deploy/remote-exec.mjs +99 -0
- package/src/deploy/snapshots.mjs +35 -0
- package/src/main.mjs +652 -0
- package/src/project/bootstrap.mjs +147 -0
- package/src/release-node.mjs +33 -182
- package/src/release-packagist.mjs +20 -135
- package/src/runtime/local-command.mjs +18 -0
- package/src/runtime/prompt.mjs +14 -0
- package/src/runtime/ssh-client.mjs +14 -0
- package/src/ssh/index.mjs +8 -0
- package/src/ssh/keys.mjs +146 -0
- package/src/ssh/ssh.mjs +124 -0
- package/src/utils/command.mjs +92 -0
- package/src/utils/config-flow.mjs +284 -0
- package/src/utils/git.mjs +91 -0
- package/src/utils/id.mjs +6 -0
- package/src/utils/log-file.mjs +76 -0
- package/src/utils/output.mjs +29 -0
- package/src/utils/paths.mjs +28 -0
- package/src/utils/remote-path.mjs +23 -0
- package/src/utils/task-planner.mjs +96 -0
- package/src/version-checker.mjs +0 -1
- package/src/index.mjs +0 -2135
- package/src/ssh-utils.mjs +0 -278
|
@@ -0,0 +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
|
+
|
package/src/utils/id.mjs
ADDED
|
@@ -0,0 +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
|
+
|
|
@@ -0,0 +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
|
+
|
|
@@ -0,0 +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
|
+
|
|
@@ -0,0 +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
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export function planLaravelDeploymentTasks({
|
|
2
|
+
branch,
|
|
3
|
+
isLaravel,
|
|
4
|
+
changedFiles,
|
|
5
|
+
horizonConfigured = false
|
|
6
|
+
}) {
|
|
7
|
+
const safeChangedFiles = Array.isArray(changedFiles) ? changedFiles : []
|
|
8
|
+
|
|
9
|
+
const shouldRunComposer =
|
|
10
|
+
isLaravel &&
|
|
11
|
+
safeChangedFiles.some(
|
|
12
|
+
(file) =>
|
|
13
|
+
file === 'composer.json' ||
|
|
14
|
+
file === 'composer.lock' ||
|
|
15
|
+
file.endsWith('/composer.json') ||
|
|
16
|
+
file.endsWith('/composer.lock')
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const shouldRunMigrations =
|
|
20
|
+
isLaravel &&
|
|
21
|
+
safeChangedFiles.some((file) => file.startsWith('database/migrations/') && file.endsWith('.php'))
|
|
22
|
+
|
|
23
|
+
const hasPhpChanges = isLaravel && safeChangedFiles.some((file) => file.endsWith('.php'))
|
|
24
|
+
|
|
25
|
+
const shouldRunNpmInstall =
|
|
26
|
+
isLaravel &&
|
|
27
|
+
safeChangedFiles.some(
|
|
28
|
+
(file) =>
|
|
29
|
+
file === 'package.json' ||
|
|
30
|
+
file === 'package-lock.json' ||
|
|
31
|
+
file.endsWith('/package.json') ||
|
|
32
|
+
file.endsWith('/package-lock.json')
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const hasFrontendChanges =
|
|
36
|
+
isLaravel &&
|
|
37
|
+
safeChangedFiles.some((file) =>
|
|
38
|
+
['.vue', '.css', '.scss', '.js', '.ts', '.tsx', '.less'].some((ext) => file.endsWith(ext))
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
const shouldRunBuild = isLaravel && (hasFrontendChanges || shouldRunNpmInstall)
|
|
42
|
+
const shouldClearCaches = hasPhpChanges
|
|
43
|
+
const shouldRestartQueues = hasPhpChanges
|
|
44
|
+
|
|
45
|
+
const steps = [
|
|
46
|
+
{
|
|
47
|
+
label: `Pull latest changes for ${branch}`,
|
|
48
|
+
command: `git pull origin ${branch}`
|
|
49
|
+
}
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
if (shouldRunComposer) {
|
|
53
|
+
steps.push({
|
|
54
|
+
label: 'Update Composer dependencies',
|
|
55
|
+
command: 'composer update --no-dev --no-interaction --prefer-dist'
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (shouldRunMigrations) {
|
|
60
|
+
steps.push({
|
|
61
|
+
label: 'Run database migrations',
|
|
62
|
+
command: 'php artisan migrate --force'
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (shouldRunNpmInstall) {
|
|
67
|
+
steps.push({
|
|
68
|
+
label: 'Install Node dependencies',
|
|
69
|
+
command: 'npm install'
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (shouldRunBuild) {
|
|
74
|
+
steps.push({
|
|
75
|
+
label: 'Compile frontend assets',
|
|
76
|
+
command: 'npm run build'
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (shouldClearCaches) {
|
|
81
|
+
steps.push({
|
|
82
|
+
label: 'Clear Laravel caches',
|
|
83
|
+
command: 'php artisan cache:clear && php artisan config:clear && php artisan view:clear'
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (shouldRestartQueues) {
|
|
88
|
+
steps.push({
|
|
89
|
+
label: horizonConfigured ? 'Restart Horizon workers' : 'Restart queue workers',
|
|
90
|
+
command: horizonConfigured ? 'php artisan horizon:terminate' : 'php artisan queue:restart'
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return steps
|
|
95
|
+
}
|
|
96
|
+
|
package/src/version-checker.mjs
CHANGED