@wyxos/zephyr 0.2.18 → 0.2.20
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 +19 -1
- package/bin/zephyr.mjs +21 -6
- package/package.json +3 -2
- 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/index.mjs +58 -2110
- package/src/main.mjs +652 -0
- package/src/project/bootstrap.mjs +147 -0
- package/src/release-node.mjs +13 -180
- package/src/release-packagist.mjs +13 -146
- 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 +134 -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/ssh-utils.mjs +0 -289
package/README.md
CHANGED
|
@@ -22,6 +22,22 @@ Navigate to your project directory and run:
|
|
|
22
22
|
zephyr
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
See all flags:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
zephyr --help
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Common flags:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
# Run a release workflow
|
|
35
|
+
zephyr --type node
|
|
36
|
+
|
|
37
|
+
# Skip the best-effort update check for this run
|
|
38
|
+
zephyr --skip-version-check
|
|
39
|
+
```
|
|
40
|
+
|
|
25
41
|
Follow the interactive prompts to configure your deployment target:
|
|
26
42
|
- Server name and IP address
|
|
27
43
|
- Project path on the remote server
|
|
@@ -34,7 +50,9 @@ Configuration is saved automatically for future deployments.
|
|
|
34
50
|
|
|
35
51
|
When run via `npx`, Zephyr can prompt to re-run itself using the latest published version.
|
|
36
52
|
|
|
37
|
-
- **Skip update check**:
|
|
53
|
+
- **Skip update check**:
|
|
54
|
+
- Set `ZEPHYR_SKIP_VERSION_CHECK=1`, or
|
|
55
|
+
- Use `zephyr --skip-version-check`
|
|
38
56
|
|
|
39
57
|
## Features
|
|
40
58
|
|
package/bin/zephyr.mjs
CHANGED
|
@@ -1,14 +1,29 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import process from 'node:process'
|
|
3
|
-
import {
|
|
3
|
+
import { Command } from 'commander'
|
|
4
|
+
import chalk from 'chalk'
|
|
5
|
+
import { main } from '../src/main.mjs'
|
|
6
|
+
import { createChalkLogger } from '../src/utils/output.mjs'
|
|
4
7
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
const
|
|
8
|
-
|
|
8
|
+
const { logError } = createChalkLogger(chalk)
|
|
9
|
+
|
|
10
|
+
const program = new Command()
|
|
11
|
+
|
|
12
|
+
program
|
|
13
|
+
.name('zephyr')
|
|
14
|
+
.description('A streamlined deployment tool for web applications with intelligent Laravel project detection')
|
|
15
|
+
.option('--type <type>', 'Release type (node|vue|packagist)')
|
|
16
|
+
.option('--skip-version-check', 'Skip the version check for this run')
|
|
17
|
+
|
|
18
|
+
program.parse(process.argv)
|
|
19
|
+
const options = program.opts()
|
|
20
|
+
|
|
21
|
+
if (options.skipVersionCheck) {
|
|
22
|
+
process.env.ZEPHYR_SKIP_VERSION_CHECK = '1'
|
|
23
|
+
}
|
|
9
24
|
|
|
10
25
|
try {
|
|
11
|
-
await main(
|
|
26
|
+
await main(options.type ?? null)
|
|
12
27
|
} catch (error) {
|
|
13
28
|
logError(error?.message || String(error))
|
|
14
29
|
process.exitCode = 1
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wyxos/zephyr",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.20",
|
|
4
4
|
"description": "A streamlined deployment tool for web applications with intelligent Laravel project detection",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.mjs",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.mjs",
|
|
9
|
-
"./ssh
|
|
9
|
+
"./ssh": "./src/ssh/index.mjs"
|
|
10
10
|
},
|
|
11
11
|
"bin": {
|
|
12
12
|
"zephyr": "bin/zephyr.mjs"
|
|
@@ -44,6 +44,7 @@
|
|
|
44
44
|
],
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"chalk": "5.3.0",
|
|
47
|
+
"commander": "11.1.0",
|
|
47
48
|
"inquirer": "^9.2.12",
|
|
48
49
|
"node-ssh": "^13.1.0",
|
|
49
50
|
"semver": "^7.6.3"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
|
|
3
|
+
import { ensureDirectory, getProjectConfigDir, getProjectConfigPath } from '../utils/paths.mjs'
|
|
4
|
+
import { generateId } from '../utils/id.mjs'
|
|
5
|
+
|
|
6
|
+
export function migrateApps(apps, servers) {
|
|
7
|
+
if (!Array.isArray(apps)) {
|
|
8
|
+
return { apps: [], needsMigration: false }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const serverNameToId = new Map()
|
|
12
|
+
servers.forEach((server) => {
|
|
13
|
+
if (server.id && server.serverName) {
|
|
14
|
+
serverNameToId.set(server.serverName, server.id)
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
let needsMigration = false
|
|
19
|
+
const migrated = apps.map((app) => {
|
|
20
|
+
const updated = { ...app }
|
|
21
|
+
|
|
22
|
+
if (!app.id) {
|
|
23
|
+
needsMigration = true
|
|
24
|
+
updated.id = generateId()
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (app.serverName && !app.serverId) {
|
|
28
|
+
const serverId = serverNameToId.get(app.serverName)
|
|
29
|
+
if (serverId) {
|
|
30
|
+
needsMigration = true
|
|
31
|
+
updated.serverId = serverId
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return updated
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
return { apps: migrated, needsMigration }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function migratePresets(presets, apps) {
|
|
42
|
+
if (!Array.isArray(presets)) {
|
|
43
|
+
return { presets: [], needsMigration: false }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const keyToAppId = new Map()
|
|
47
|
+
apps.forEach((app) => {
|
|
48
|
+
if (app.id && app.serverName && app.projectPath) {
|
|
49
|
+
const key = `${app.serverName}:${app.projectPath}`
|
|
50
|
+
keyToAppId.set(key, app.id)
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
let needsMigration = false
|
|
55
|
+
const migrated = presets.map((preset) => {
|
|
56
|
+
const updated = { ...preset }
|
|
57
|
+
|
|
58
|
+
if (preset.key && !preset.appId) {
|
|
59
|
+
const appId = keyToAppId.get(preset.key)
|
|
60
|
+
if (appId) {
|
|
61
|
+
needsMigration = true
|
|
62
|
+
updated.appId = appId
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return updated
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
return { presets: migrated, needsMigration }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function loadProjectConfig(rootDir, servers = [], { logSuccess, logWarning } = {}) {
|
|
73
|
+
const configPath = getProjectConfigPath(rootDir)
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const raw = await fs.readFile(configPath, 'utf8')
|
|
77
|
+
const data = JSON.parse(raw)
|
|
78
|
+
const apps = Array.isArray(data?.apps) ? data.apps : []
|
|
79
|
+
const presets = Array.isArray(data?.presets) ? data.presets : []
|
|
80
|
+
|
|
81
|
+
const { apps: migratedApps, needsMigration: appsNeedMigration } = migrateApps(apps, servers)
|
|
82
|
+
const { presets: migratedPresets, needsMigration: presetsNeedMigration } = migratePresets(presets, migratedApps)
|
|
83
|
+
|
|
84
|
+
if (appsNeedMigration || presetsNeedMigration) {
|
|
85
|
+
await saveProjectConfig(rootDir, {
|
|
86
|
+
apps: migratedApps,
|
|
87
|
+
presets: migratedPresets
|
|
88
|
+
})
|
|
89
|
+
logSuccess?.('Migrated project configuration to use unique IDs.')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { apps: migratedApps, presets: migratedPresets }
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (error.code === 'ENOENT') {
|
|
95
|
+
return { apps: [], presets: [] }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
logWarning?.('Failed to read .zephyr/config.json, starting with an empty list of apps.')
|
|
99
|
+
return { apps: [], presets: [] }
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function saveProjectConfig(rootDir, config) {
|
|
104
|
+
const configDir = getProjectConfigDir(rootDir)
|
|
105
|
+
await ensureDirectory(configDir)
|
|
106
|
+
|
|
107
|
+
const payload = JSON.stringify(
|
|
108
|
+
{
|
|
109
|
+
apps: config.apps ?? [],
|
|
110
|
+
presets: config.presets ?? []
|
|
111
|
+
},
|
|
112
|
+
null,
|
|
113
|
+
2
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
await fs.writeFile(getProjectConfigPath(rootDir), `${payload}\n`)
|
|
117
|
+
}
|
|
118
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
|
|
5
|
+
import { ensureDirectory } from '../utils/paths.mjs'
|
|
6
|
+
import { generateId } from '../utils/id.mjs'
|
|
7
|
+
|
|
8
|
+
const GLOBAL_CONFIG_DIR = path.join(os.homedir(), '.config', 'zephyr')
|
|
9
|
+
const SERVERS_FILE = path.join(GLOBAL_CONFIG_DIR, 'servers.json')
|
|
10
|
+
|
|
11
|
+
export function migrateServers(servers) {
|
|
12
|
+
if (!Array.isArray(servers)) {
|
|
13
|
+
return { servers: [], needsMigration: false }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let needsMigration = false
|
|
17
|
+
const migrated = servers.map((server) => {
|
|
18
|
+
if (!server.id) {
|
|
19
|
+
needsMigration = true
|
|
20
|
+
return { ...server, id: generateId() }
|
|
21
|
+
}
|
|
22
|
+
return server
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return { servers: migrated, needsMigration }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function loadServers({ logSuccess, logWarning } = {}) {
|
|
29
|
+
try {
|
|
30
|
+
const raw = await fs.readFile(SERVERS_FILE, 'utf8')
|
|
31
|
+
const data = JSON.parse(raw)
|
|
32
|
+
const servers = Array.isArray(data) ? data : []
|
|
33
|
+
|
|
34
|
+
const { servers: migrated, needsMigration } = migrateServers(servers)
|
|
35
|
+
|
|
36
|
+
if (needsMigration) {
|
|
37
|
+
await saveServers(migrated)
|
|
38
|
+
logSuccess?.('Migrated servers configuration to use unique IDs.')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return migrated
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === 'ENOENT') {
|
|
44
|
+
return []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
logWarning?.('Failed to read servers.json, starting with an empty list.')
|
|
48
|
+
return []
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function saveServers(servers) {
|
|
53
|
+
await ensureDirectory(GLOBAL_CONFIG_DIR)
|
|
54
|
+
const payload = JSON.stringify(servers, null, 2)
|
|
55
|
+
await fs.writeFile(SERVERS_FILE, `${payload}\n`)
|
|
56
|
+
}
|
|
57
|
+
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
-
import { spawn } from 'node:child_process'
|
|
4
3
|
import process from 'node:process'
|
|
5
4
|
import chalk from 'chalk'
|
|
6
|
-
|
|
7
|
-
const IS_WINDOWS = process.platform === 'win32'
|
|
5
|
+
import { runCommand as runCommandBase, runCommandCapture as runCommandCaptureBase } from './utils/command.mjs'
|
|
8
6
|
|
|
9
7
|
function isLocalPathOutsideRepo(depPath, rootDir) {
|
|
10
8
|
if (!depPath || typeof depPath !== 'string') {
|
|
@@ -218,45 +216,13 @@ async function updateComposerJsonDependency(rootDir, packageName, newVersion, fi
|
|
|
218
216
|
}
|
|
219
217
|
|
|
220
218
|
async function runCommand(command, args, { cwd = process.cwd(), capture = false } = {}) {
|
|
221
|
-
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const spawnOptions = {
|
|
227
|
-
stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
|
|
228
|
-
cwd
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const child = spawn(resolvedCommand, args, spawnOptions)
|
|
232
|
-
let stdout = ''
|
|
233
|
-
let stderr = ''
|
|
234
|
-
|
|
235
|
-
if (capture) {
|
|
236
|
-
child.stdout.on('data', (chunk) => {
|
|
237
|
-
stdout += chunk
|
|
238
|
-
})
|
|
239
|
-
|
|
240
|
-
child.stderr.on('data', (chunk) => {
|
|
241
|
-
stderr += chunk
|
|
242
|
-
})
|
|
243
|
-
}
|
|
219
|
+
if (capture) {
|
|
220
|
+
const { stdout, stderr } = await runCommandCaptureBase(command, args, { cwd })
|
|
221
|
+
return { stdout: stdout.trim(), stderr: stderr.trim() }
|
|
222
|
+
}
|
|
244
223
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
if (code === 0) {
|
|
248
|
-
resolve(capture ? { stdout: stdout.trim(), stderr: stderr.trim() } : undefined)
|
|
249
|
-
} else {
|
|
250
|
-
const error = new Error(`Command failed (${code}): ${resolvedCommand} ${args.join(' ')}`)
|
|
251
|
-
if (capture) {
|
|
252
|
-
error.stdout = stdout
|
|
253
|
-
error.stderr = stderr
|
|
254
|
-
}
|
|
255
|
-
error.exitCode = code
|
|
256
|
-
reject(error)
|
|
257
|
-
}
|
|
258
|
-
})
|
|
259
|
-
})
|
|
224
|
+
await runCommandBase(command, args, { cwd })
|
|
225
|
+
return undefined
|
|
260
226
|
}
|
|
261
227
|
|
|
262
228
|
async function getGitStatus(rootDir) {
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { getCurrentBranch as getCurrentBranchImpl, getUpstreamRef as getUpstreamRefImpl } from '../utils/git.mjs'
|
|
2
|
+
|
|
3
|
+
export async function getCurrentBranch(rootDir) {
|
|
4
|
+
const branch = await getCurrentBranchImpl(rootDir)
|
|
5
|
+
return branch ?? ''
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function getGitStatus(rootDir, { runCommandCapture } = {}) {
|
|
9
|
+
const output = await runCommandCapture('git', ['status', '--porcelain'], { cwd: rootDir })
|
|
10
|
+
return output.trim()
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function hasStagedChanges(statusOutput) {
|
|
14
|
+
if (!statusOutput || statusOutput.length === 0) {
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const lines = statusOutput.split('\n').filter((line) => line.trim().length > 0)
|
|
19
|
+
|
|
20
|
+
return lines.some((line) => {
|
|
21
|
+
const firstChar = line[0]
|
|
22
|
+
return firstChar && firstChar !== ' ' && firstChar !== '?'
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function hasUncommittedChanges(rootDir, { getGitStatus: getGitStatusFn } = {}) {
|
|
27
|
+
const status = await getGitStatusFn(rootDir)
|
|
28
|
+
return status.length > 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function getUpstreamRef(rootDir) {
|
|
32
|
+
return await getUpstreamRefImpl(rootDir)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function ensureCommittedChangesPushed(targetBranch, rootDir, {
|
|
36
|
+
runCommand,
|
|
37
|
+
runCommandCapture,
|
|
38
|
+
logProcessing,
|
|
39
|
+
logSuccess,
|
|
40
|
+
logWarning,
|
|
41
|
+
getUpstreamRef: getUpstreamRefFn = getUpstreamRef
|
|
42
|
+
} = {}) {
|
|
43
|
+
const upstreamRef = await getUpstreamRefFn(rootDir)
|
|
44
|
+
|
|
45
|
+
if (!upstreamRef) {
|
|
46
|
+
logWarning?.(`Branch ${targetBranch} does not track a remote upstream; skipping automatic push of committed changes.`)
|
|
47
|
+
return { pushed: false, upstreamRef: null }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const [remoteName, ...upstreamParts] = upstreamRef.split('/')
|
|
51
|
+
const upstreamBranch = upstreamParts.join('/')
|
|
52
|
+
|
|
53
|
+
if (!remoteName || !upstreamBranch) {
|
|
54
|
+
logWarning?.(`Unable to determine remote destination for ${targetBranch}. Skipping automatic push.`)
|
|
55
|
+
return { pushed: false, upstreamRef }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
await runCommand('git', ['fetch', remoteName], { cwd: rootDir, silent: true })
|
|
60
|
+
} catch (error) {
|
|
61
|
+
logWarning?.(`Unable to fetch from ${remoteName} before push: ${error.message}`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let remoteExists = true
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
await runCommand('git', ['show-ref', '--verify', '--quiet', `refs/remotes/${upstreamRef}`], {
|
|
68
|
+
cwd: rootDir,
|
|
69
|
+
silent: true
|
|
70
|
+
})
|
|
71
|
+
} catch {
|
|
72
|
+
remoteExists = false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let aheadCount = 0
|
|
76
|
+
let behindCount = 0
|
|
77
|
+
|
|
78
|
+
if (remoteExists) {
|
|
79
|
+
const aheadOutput = await runCommandCapture('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], { cwd: rootDir })
|
|
80
|
+
aheadCount = parseInt(aheadOutput.trim() || '0', 10)
|
|
81
|
+
|
|
82
|
+
const behindOutput = await runCommandCapture('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], { cwd: rootDir })
|
|
83
|
+
behindCount = parseInt(behindOutput.trim() || '0', 10)
|
|
84
|
+
} else {
|
|
85
|
+
aheadCount = 1
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (Number.isFinite(behindCount) && behindCount > 0) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Local branch ${targetBranch} is behind ${upstreamRef} by ${behindCount} commit${behindCount === 1 ? '' : 's'}. Pull or rebase before deployment.`
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (!Number.isFinite(aheadCount) || aheadCount <= 0) {
|
|
95
|
+
return { pushed: false, upstreamRef }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const commitLabel = aheadCount === 1 ? 'commit' : 'commits'
|
|
99
|
+
logProcessing?.(`Found ${aheadCount} ${commitLabel} not yet pushed to ${upstreamRef}. Pushing before deployment...`)
|
|
100
|
+
|
|
101
|
+
await runCommandCapture('git', ['push', remoteName, `${targetBranch}:${upstreamBranch}`], { cwd: rootDir })
|
|
102
|
+
logSuccess?.(`Pushed committed changes to ${upstreamRef}.`)
|
|
103
|
+
|
|
104
|
+
return { pushed: true, upstreamRef }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd(), {
|
|
108
|
+
runPrompt,
|
|
109
|
+
runCommand,
|
|
110
|
+
runCommandCapture,
|
|
111
|
+
logProcessing,
|
|
112
|
+
logSuccess,
|
|
113
|
+
logWarning,
|
|
114
|
+
getCurrentBranch: getCurrentBranchFn = getCurrentBranch,
|
|
115
|
+
getGitStatus: getGitStatusFn = (dir) => getGitStatus(dir, { runCommandCapture }),
|
|
116
|
+
ensureCommittedChangesPushed: ensureCommittedChangesPushedFn = (branch, dir) =>
|
|
117
|
+
ensureCommittedChangesPushed(branch, dir, {
|
|
118
|
+
runCommand,
|
|
119
|
+
runCommandCapture,
|
|
120
|
+
logProcessing,
|
|
121
|
+
logSuccess,
|
|
122
|
+
logWarning
|
|
123
|
+
})
|
|
124
|
+
} = {}) {
|
|
125
|
+
if (!targetBranch) {
|
|
126
|
+
throw new Error('Deployment branch is not defined in the release configuration.')
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const currentBranch = await getCurrentBranchFn(rootDir)
|
|
130
|
+
|
|
131
|
+
if (!currentBranch) {
|
|
132
|
+
throw new Error('Unable to determine the current git branch. Ensure this is a git repository.')
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const initialStatus = await getGitStatusFn(rootDir)
|
|
136
|
+
const hasPendingChanges = initialStatus.length > 0
|
|
137
|
+
|
|
138
|
+
const statusReport = await runCommandCapture('git', ['status', '--short', '--branch'], { cwd: rootDir })
|
|
139
|
+
const lines = statusReport.split(/\r?\n/)
|
|
140
|
+
const branchLine = lines[0] || ''
|
|
141
|
+
const aheadMatch = branchLine.match(/ahead (\d+)/)
|
|
142
|
+
const behindMatch = branchLine.match(/behind (\d+)/)
|
|
143
|
+
const aheadCount = aheadMatch ? parseInt(aheadMatch[1], 10) : 0
|
|
144
|
+
const behindCount = behindMatch ? parseInt(behindMatch[1], 10) : 0
|
|
145
|
+
|
|
146
|
+
if (aheadCount > 0) {
|
|
147
|
+
logWarning?.(`Local branch ${currentBranch} is ahead of upstream by ${aheadCount} commit${aheadCount === 1 ? '' : 's'}.`)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (behindCount > 0) {
|
|
151
|
+
logProcessing?.(`Synchronizing local branch ${currentBranch} with its upstream...`)
|
|
152
|
+
try {
|
|
153
|
+
await runCommand('git', ['pull', '--ff-only'], { cwd: rootDir })
|
|
154
|
+
logSuccess?.('Local branch fast-forwarded with upstream changes.')
|
|
155
|
+
} catch (error) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Unable to fast-forward ${currentBranch} with upstream changes. Resolve conflicts manually, then rerun the deployment.\n${error.message}`
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (currentBranch !== targetBranch) {
|
|
163
|
+
if (hasPendingChanges) {
|
|
164
|
+
throw new Error(
|
|
165
|
+
`Local repository has uncommitted changes on ${currentBranch}. Commit or stash them before switching to ${targetBranch}.`
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
logProcessing?.(`Switching local repository from ${currentBranch} to ${targetBranch}...`)
|
|
170
|
+
await runCommand('git', ['checkout', targetBranch], { cwd: rootDir })
|
|
171
|
+
logSuccess?.(`Checked out ${targetBranch} locally.`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const statusAfterCheckout = currentBranch === targetBranch ? initialStatus : await getGitStatusFn(rootDir)
|
|
175
|
+
|
|
176
|
+
if (statusAfterCheckout.length === 0) {
|
|
177
|
+
await ensureCommittedChangesPushedFn(targetBranch, rootDir)
|
|
178
|
+
logProcessing?.('Local repository is clean. Proceeding with deployment.')
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!hasStagedChanges(statusAfterCheckout)) {
|
|
183
|
+
await ensureCommittedChangesPushedFn(targetBranch, rootDir)
|
|
184
|
+
logProcessing?.('No staged changes detected. Unstaged or untracked files will not affect deployment. Proceeding with deployment.')
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
logWarning?.(`Staged changes detected on ${targetBranch}. A commit is required before deployment.`)
|
|
189
|
+
|
|
190
|
+
const { commitMessage } = await runPrompt([
|
|
191
|
+
{
|
|
192
|
+
type: 'input',
|
|
193
|
+
name: 'commitMessage',
|
|
194
|
+
message: 'Enter a commit message for pending changes before deployment',
|
|
195
|
+
validate: (value) => (value && value.trim().length > 0 ? true : 'Commit message cannot be empty.')
|
|
196
|
+
}
|
|
197
|
+
])
|
|
198
|
+
|
|
199
|
+
const message = commitMessage.trim()
|
|
200
|
+
|
|
201
|
+
logProcessing?.('Committing staged changes before deployment...')
|
|
202
|
+
await runCommand('git', ['commit', '-m', message], { cwd: rootDir })
|
|
203
|
+
await runCommand('git', ['push', 'origin', targetBranch], { cwd: rootDir })
|
|
204
|
+
logSuccess?.(`Committed and pushed changes to origin/${targetBranch}.`)
|
|
205
|
+
|
|
206
|
+
const finalStatus = await getGitStatusFn(rootDir)
|
|
207
|
+
|
|
208
|
+
if (finalStatus.length > 0) {
|
|
209
|
+
throw new Error('Local repository still has uncommitted changes after commit. Aborting deployment.')
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await ensureCommittedChangesPushedFn(targetBranch, rootDir)
|
|
213
|
+
logProcessing?.('Local repository is clean after committing pending changes.')
|
|
214
|
+
}
|
|
215
|
+
|