@wyxos/zephyr 0.3.3 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -0
- package/bin/zephyr.mjs +15 -23
- package/package.json +1 -1
- package/src/application/configuration/select-deployment-target.mjs +94 -9
- package/src/application/deploy/build-remote-deployment-plan.mjs +134 -3
- package/src/application/deploy/execute-remote-deployment-plan.mjs +2 -2
- package/src/application/deploy/resolve-pending-snapshot.mjs +21 -1
- package/src/application/deploy/run-deployment.mjs +36 -7
- package/src/application/release/release-node-package.mjs +64 -17
- package/src/application/release/release-packagist-package.mjs +54 -19
- package/src/cli/options.mjs +122 -0
- package/src/config/project.mjs +32 -1
- package/src/config/servers.mjs +32 -2
- package/src/dependency-scanner.mjs +11 -1
- package/src/deploy/locks.mjs +40 -24
- package/src/main.mjs +199 -71
- package/src/project/bootstrap.mjs +10 -1
- package/src/release/shared.mjs +15 -5
- package/src/release-node.mjs +27 -17
- package/src/release-packagist.mjs +26 -15
- package/src/runtime/app-context.mjs +33 -6
- package/src/runtime/errors.mjs +46 -0
- package/src/runtime/local-command.mjs +12 -3
- package/src/runtime/prompt.mjs +40 -2
- package/src/utils/output.mjs +45 -0
package/README.md
CHANGED
|
@@ -43,6 +43,15 @@ zephyr
|
|
|
43
43
|
# Deploy an app and bump the local npm package version first
|
|
44
44
|
zephyr minor
|
|
45
45
|
|
|
46
|
+
# Deploy a configured app non-interactively
|
|
47
|
+
zephyr --non-interactive --preset wyxos-release --maintenance off
|
|
48
|
+
|
|
49
|
+
# Resume a pending non-interactive deployment
|
|
50
|
+
zephyr --non-interactive --preset wyxos-release --resume-pending --maintenance off
|
|
51
|
+
|
|
52
|
+
# Emit NDJSON events for automation or agent tooling
|
|
53
|
+
zephyr --non-interactive --json --preset wyxos-release --maintenance on
|
|
54
|
+
|
|
46
55
|
# Release a Node/Vue package (defaults to a patch bump)
|
|
47
56
|
zephyr --type node
|
|
48
57
|
|
|
@@ -55,6 +64,67 @@ zephyr --type packagist patch
|
|
|
55
64
|
|
|
56
65
|
When `--type node` or `--type vue` is used without a bump argument, Zephyr defaults to `patch`.
|
|
57
66
|
|
|
67
|
+
## Interactive and Non-Interactive Modes
|
|
68
|
+
|
|
69
|
+
Interactive mode remains the default and is the best fit for first-time setup, config repair, and one-off deployments.
|
|
70
|
+
|
|
71
|
+
Non-interactive mode is strict and is intended for already-configured projects:
|
|
72
|
+
|
|
73
|
+
- `--non-interactive` fails instead of prompting
|
|
74
|
+
- app deployments require `--preset <name>`
|
|
75
|
+
- Laravel app deployments require `--maintenance on|off` unless resuming a saved snapshot that already contains the choice
|
|
76
|
+
- pending deployment snapshots require either `--resume-pending` or `--discard-pending`
|
|
77
|
+
- stale remote locks are never auto-removed in non-interactive mode
|
|
78
|
+
- `--json` is only supported together with `--non-interactive`
|
|
79
|
+
|
|
80
|
+
If Zephyr would normally prompt to:
|
|
81
|
+
|
|
82
|
+
- create or repair config
|
|
83
|
+
- save a preset
|
|
84
|
+
- install the `release` script
|
|
85
|
+
- confirm local path dependency updates
|
|
86
|
+
- resolve a stale remote lock
|
|
87
|
+
|
|
88
|
+
then non-interactive mode stops immediately with a clear error instead.
|
|
89
|
+
|
|
90
|
+
## AI Agents and Automation
|
|
91
|
+
|
|
92
|
+
Zephyr can be used safely by Codex, CI jobs, or other automation once configuration is already in place.
|
|
93
|
+
|
|
94
|
+
Recommended pattern for app deployments:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
zephyr --non-interactive --json --preset wyxos-release --maintenance off
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Recommended pattern for package releases:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
zephyr --type node --non-interactive --json minor
|
|
104
|
+
zephyr --type packagist --non-interactive --json patch
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
In `--json` mode Zephyr emits NDJSON events on `stdout` with a stable shape:
|
|
108
|
+
|
|
109
|
+
- `run_started`
|
|
110
|
+
- `log`
|
|
111
|
+
- `prompt_required`
|
|
112
|
+
- `run_completed`
|
|
113
|
+
- `run_failed`
|
|
114
|
+
|
|
115
|
+
Each event includes:
|
|
116
|
+
|
|
117
|
+
- `event`
|
|
118
|
+
- `timestamp`
|
|
119
|
+
- `workflow`
|
|
120
|
+
- `message`
|
|
121
|
+
- `level` where relevant
|
|
122
|
+
- `data`
|
|
123
|
+
|
|
124
|
+
`run_failed` also includes a stable `code` field for automation checks.
|
|
125
|
+
|
|
126
|
+
In `--json` mode, Zephyr reserves `stdout` for NDJSON events and routes inherited local command output to `stderr` so agent parsers do not get corrupted.
|
|
127
|
+
|
|
58
128
|
On a first run inside a project with `package.json`, Zephyr can:
|
|
59
129
|
- add `.zephyr/` to `.gitignore`
|
|
60
130
|
- add a `release` script that runs `npx @wyxos/zephyr@latest`
|
|
@@ -79,6 +149,7 @@ npm run release
|
|
|
79
149
|
- `npm run release` is the recommended app/package entrypoint once the release script has been installed.
|
|
80
150
|
- For `--type node` workflows, Zephyr runs your project's `lint` script when present.
|
|
81
151
|
- For `--type node` workflows, Zephyr runs `test:run` or `test` when present.
|
|
152
|
+
- For non-interactive app deploys, use a saved preset name instead of relying on prompt fallback.
|
|
82
153
|
|
|
83
154
|
## Features
|
|
84
155
|
|
package/bin/zephyr.mjs
CHANGED
|
@@ -1,30 +1,22 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import process from 'node:process'
|
|
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'
|
|
7
3
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
program
|
|
13
|
-
.name('zephyr')
|
|
14
|
-
.description('A streamlined deployment tool for web applications with intelligent Laravel project detection')
|
|
15
|
-
.option('--type <type>', 'Workflow type (node|vue|packagist). Omit for normal app deployments.')
|
|
16
|
-
.argument(
|
|
17
|
-
'[version]',
|
|
18
|
-
'Version or npm bump type for deployments (e.g. 1.2.3, patch, minor, major). --type node/vue/packagist workflows accept bump types only and default to patch.'
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
program.parse(process.argv)
|
|
22
|
-
const options = program.opts()
|
|
23
|
-
const versionArg = program.args[0] ?? null
|
|
4
|
+
import {parseCliOptions} from '../src/cli/options.mjs'
|
|
5
|
+
import {main} from '../src/main.mjs'
|
|
6
|
+
import {writeStderrLine} from '../src/utils/output.mjs'
|
|
24
7
|
|
|
8
|
+
let options
|
|
25
9
|
try {
|
|
26
|
-
|
|
10
|
+
options = parseCliOptions()
|
|
27
11
|
} catch (error) {
|
|
28
|
-
|
|
29
|
-
|
|
12
|
+
writeStderrLine(error.message)
|
|
13
|
+
process.exitCode = 1
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (options) {
|
|
17
|
+
try {
|
|
18
|
+
await main(options)
|
|
19
|
+
} catch {
|
|
20
|
+
process.exitCode = 1
|
|
21
|
+
}
|
|
30
22
|
}
|
package/package.json
CHANGED
|
@@ -1,22 +1,82 @@
|
|
|
1
1
|
import {writeStdoutLine} from '../../utils/output.mjs'
|
|
2
2
|
import {loadServers} from '../../config/servers.mjs'
|
|
3
3
|
import {loadProjectConfig, removePreset, saveProjectConfig} from '../../config/project.mjs'
|
|
4
|
+
import {ZephyrError} from '../../runtime/errors.mjs'
|
|
5
|
+
|
|
6
|
+
function findPresetByName(projectConfig, presetName) {
|
|
7
|
+
const presets = projectConfig?.presets ?? []
|
|
8
|
+
return presets.find((entry) => entry?.name === presetName) ?? null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function resolvePresetNonInteractive(projectConfig, servers, preset, presetName) {
|
|
12
|
+
if (!preset) {
|
|
13
|
+
throw new ZephyrError(
|
|
14
|
+
`Zephyr cannot run non-interactively because preset "${presetName}" was not found in .zephyr/config.json.`,
|
|
15
|
+
{code: 'ZEPHYR_PRESET_NOT_FOUND'}
|
|
16
|
+
)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (!preset.appId) {
|
|
20
|
+
throw new ZephyrError(
|
|
21
|
+
`Zephyr cannot run non-interactively because preset "${preset.name || presetName}" uses a legacy or invalid format. Rerun interactively once to repair .zephyr/config.json.`,
|
|
22
|
+
{code: 'ZEPHYR_PRESET_REPAIR_REQUIRED'}
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const apps = projectConfig?.apps ?? []
|
|
27
|
+
const appConfig = apps.find((app) => app.id === preset.appId)
|
|
28
|
+
if (!appConfig) {
|
|
29
|
+
throw new ZephyrError(
|
|
30
|
+
`Zephyr cannot run non-interactively because preset "${preset.name || presetName}" references an application that no longer exists.`,
|
|
31
|
+
{code: 'ZEPHYR_PRESET_INVALID'}
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const server = servers.find((entry) => entry.id === appConfig.serverId || entry.serverName === appConfig.serverName)
|
|
36
|
+
if (!server) {
|
|
37
|
+
throw new ZephyrError(
|
|
38
|
+
`Zephyr cannot run non-interactively because preset "${preset.name || presetName}" references a server that no longer exists.`,
|
|
39
|
+
{code: 'ZEPHYR_PRESET_INVALID'}
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
server,
|
|
45
|
+
appConfig,
|
|
46
|
+
branch: preset.branch || appConfig.branch
|
|
47
|
+
}
|
|
48
|
+
}
|
|
4
49
|
|
|
5
50
|
export async function selectDeploymentTarget(rootDir, {
|
|
6
51
|
configurationService,
|
|
7
52
|
runPrompt,
|
|
8
53
|
logProcessing,
|
|
9
54
|
logSuccess,
|
|
10
|
-
logWarning
|
|
55
|
+
logWarning,
|
|
56
|
+
emitEvent,
|
|
57
|
+
executionMode = {}
|
|
11
58
|
} = {}) {
|
|
12
|
-
const
|
|
13
|
-
const
|
|
59
|
+
const nonInteractive = executionMode?.interactive === false
|
|
60
|
+
const servers = await loadServers({
|
|
61
|
+
logSuccess,
|
|
62
|
+
logWarning,
|
|
63
|
+
strict: nonInteractive,
|
|
64
|
+
allowMigration: !nonInteractive
|
|
65
|
+
})
|
|
66
|
+
const projectConfig = await loadProjectConfig(rootDir, servers, {
|
|
67
|
+
logSuccess,
|
|
68
|
+
logWarning,
|
|
69
|
+
strict: nonInteractive,
|
|
70
|
+
allowMigration: !nonInteractive
|
|
71
|
+
})
|
|
14
72
|
|
|
15
73
|
let server = null
|
|
16
74
|
let appConfig = null
|
|
17
75
|
let isCreatingNewPreset = false
|
|
18
76
|
|
|
19
|
-
const preset =
|
|
77
|
+
const preset = nonInteractive
|
|
78
|
+
? findPresetByName(projectConfig, executionMode.presetName)
|
|
79
|
+
: await configurationService.selectPreset(projectConfig, servers)
|
|
20
80
|
|
|
21
81
|
const removeInvalidPreset = async () => {
|
|
22
82
|
if (!preset || preset === 'create') {
|
|
@@ -34,7 +94,15 @@ export async function selectDeploymentTarget(rootDir, {
|
|
|
34
94
|
isCreatingNewPreset = true
|
|
35
95
|
}
|
|
36
96
|
|
|
37
|
-
if (
|
|
97
|
+
if (nonInteractive) {
|
|
98
|
+
const resolved = resolvePresetNonInteractive(projectConfig, servers, preset, executionMode.presetName)
|
|
99
|
+
server = resolved.server
|
|
100
|
+
appConfig = resolved.appConfig
|
|
101
|
+
appConfig = {
|
|
102
|
+
...appConfig,
|
|
103
|
+
branch: resolved.branch
|
|
104
|
+
}
|
|
105
|
+
} else if (preset === 'create') {
|
|
38
106
|
isCreatingNewPreset = true
|
|
39
107
|
server = await configurationService.selectServer(servers)
|
|
40
108
|
appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
|
|
@@ -103,7 +171,16 @@ export async function selectDeploymentTarget(rootDir, {
|
|
|
103
171
|
appConfig = await configurationService.selectApp(projectConfig, server, rootDir)
|
|
104
172
|
}
|
|
105
173
|
|
|
106
|
-
|
|
174
|
+
if (nonInteractive && (!appConfig?.sshUser || !appConfig?.sshKey)) {
|
|
175
|
+
throw new ZephyrError(
|
|
176
|
+
`Zephyr cannot run non-interactively because preset "${preset?.name || executionMode.presetName}" is missing SSH details.`,
|
|
177
|
+
{code: 'ZEPHYR_SSH_DETAILS_REQUIRED'}
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const updated = nonInteractive
|
|
182
|
+
? false
|
|
183
|
+
: await configurationService.ensureSshDetails(appConfig, rootDir)
|
|
107
184
|
|
|
108
185
|
if (updated) {
|
|
109
186
|
await saveProjectConfig(rootDir, projectConfig)
|
|
@@ -119,10 +196,18 @@ export async function selectDeploymentTarget(rootDir, {
|
|
|
119
196
|
sshKey: appConfig.sshKey
|
|
120
197
|
}
|
|
121
198
|
|
|
122
|
-
|
|
123
|
-
|
|
199
|
+
if (typeof emitEvent === 'function' && executionMode?.json) {
|
|
200
|
+
emitEvent('log', {
|
|
201
|
+
level: 'processing',
|
|
202
|
+
message: 'Selected deployment target.',
|
|
203
|
+
data: {deploymentConfig}
|
|
204
|
+
})
|
|
205
|
+
} else {
|
|
206
|
+
logProcessing?.('\nSelected deployment target:')
|
|
207
|
+
writeStdoutLine(JSON.stringify(deploymentConfig, null, 2))
|
|
208
|
+
}
|
|
124
209
|
|
|
125
|
-
if (isCreatingNewPreset || !preset) {
|
|
210
|
+
if (!nonInteractive && (isCreatingNewPreset || !preset)) {
|
|
126
211
|
const {presetName} = await runPrompt([
|
|
127
212
|
{
|
|
128
213
|
type: 'input',
|
|
@@ -1,8 +1,116 @@
|
|
|
1
1
|
import {findPhpBinary} from '../../infrastructure/php/version.mjs'
|
|
2
|
+
import {ZephyrError} from '../../runtime/errors.mjs'
|
|
2
3
|
import {planLaravelDeploymentTasks} from './plan-laravel-deployment-tasks.mjs'
|
|
3
4
|
|
|
4
5
|
const PRERENDERED_MAINTENANCE_VIEW = 'errors::503'
|
|
5
6
|
const PRERENDERED_MAINTENANCE_FILE = 'resources/views/errors/503.blade.php'
|
|
7
|
+
const LARAVEL_WRITABLE_PATHS = [
|
|
8
|
+
'bootstrap/cache',
|
|
9
|
+
'storage/framework/cache',
|
|
10
|
+
'storage/framework/views',
|
|
11
|
+
'storage/framework/sessions'
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
function escapeForSingleQuotes(value) {
|
|
15
|
+
return value.replace(/'/g, "'\\''")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isGroupWritable(mode) {
|
|
19
|
+
if (typeof mode !== 'string' || mode.length < 2) {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const groupDigit = mode.at(-2)
|
|
24
|
+
const parsed = Number.parseInt(groupDigit, 8)
|
|
25
|
+
return Number.isInteger(parsed) && (parsed & 2) === 2
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function shouldInspectLaravelWritablePaths(steps = []) {
|
|
29
|
+
return steps.some((step) => step.label === 'Clear Laravel caches')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function inspectLaravelWritablePath(ssh, remoteCwd, relativePath) {
|
|
33
|
+
const escapedPath = escapeForSingleQuotes(relativePath)
|
|
34
|
+
const command = [
|
|
35
|
+
`if [ ! -e '${escapedPath}' ]; then`,
|
|
36
|
+
' printf "__MISSING__";',
|
|
37
|
+
'else',
|
|
38
|
+
` WRITABLE="no"; [ -w '${escapedPath}' ] && WRITABLE="yes";`,
|
|
39
|
+
` OWNER=$(stat -c '%U' '${escapedPath}' 2>/dev/null || printf '?');`,
|
|
40
|
+
` GROUP=$(stat -c '%G' '${escapedPath}' 2>/dev/null || printf '?');`,
|
|
41
|
+
` MODE=$(stat -c '%a' '${escapedPath}' 2>/dev/null || printf '?');`,
|
|
42
|
+
' printf "%s|%s|%s|%s" "$WRITABLE" "$OWNER" "$GROUP" "$MODE";',
|
|
43
|
+
'fi'
|
|
44
|
+
].join(' ')
|
|
45
|
+
|
|
46
|
+
const result = await ssh.execCommand(command, {cwd: remoteCwd})
|
|
47
|
+
const output = result.stdout.trim()
|
|
48
|
+
|
|
49
|
+
if (output === '__MISSING__') {
|
|
50
|
+
return {
|
|
51
|
+
path: relativePath,
|
|
52
|
+
exists: false,
|
|
53
|
+
writable: false,
|
|
54
|
+
owner: null,
|
|
55
|
+
group: null,
|
|
56
|
+
mode: null
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const [writableFlag, owner = '?', group = '?', mode = '?'] = output.split('|')
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
path: relativePath,
|
|
64
|
+
exists: true,
|
|
65
|
+
writable: writableFlag === 'yes',
|
|
66
|
+
owner,
|
|
67
|
+
group,
|
|
68
|
+
mode
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function validateLaravelWritablePaths({
|
|
73
|
+
ssh,
|
|
74
|
+
remoteCwd,
|
|
75
|
+
sshUser,
|
|
76
|
+
steps,
|
|
77
|
+
logProcessing,
|
|
78
|
+
logWarning
|
|
79
|
+
} = {}) {
|
|
80
|
+
if (!shouldInspectLaravelWritablePaths(steps)) {
|
|
81
|
+
return
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
logProcessing?.(`Checking Laravel writable directories for deploy user ${sshUser || 'current SSH user'}...`)
|
|
85
|
+
|
|
86
|
+
const inspections = []
|
|
87
|
+
for (const relativePath of LARAVEL_WRITABLE_PATHS) {
|
|
88
|
+
inspections.push(await inspectLaravelWritablePath(ssh, remoteCwd, relativePath))
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const blockedPaths = inspections.filter((inspection) => inspection.exists && !inspection.writable)
|
|
92
|
+
if (blockedPaths.length > 0) {
|
|
93
|
+
const details = blockedPaths
|
|
94
|
+
.map((inspection) => ` - ${inspection.path} (owner ${inspection.owner}:${inspection.group}, mode ${inspection.mode})`)
|
|
95
|
+
.join('\n')
|
|
96
|
+
|
|
97
|
+
throw new Error(
|
|
98
|
+
'Laravel cache-related deployment tasks cannot run because the SSH deploy user cannot write to required directories:\n' +
|
|
99
|
+
`${details}\n` +
|
|
100
|
+
'Fix permissions before releasing. Typical fix:\n' +
|
|
101
|
+
'sudo chown -R $USER:www-data bootstrap/cache storage/framework/cache storage/framework/views storage/framework/sessions\n' +
|
|
102
|
+
'sudo chmod -R ug+rwX bootstrap/cache storage/framework/cache storage/framework/views storage/framework/sessions'
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const riskyPaths = inspections.filter((inspection) => inspection.exists && inspection.writable && !isGroupWritable(inspection.mode))
|
|
107
|
+
for (const inspection of riskyPaths) {
|
|
108
|
+
logWarning?.(
|
|
109
|
+
`${inspection.path} is writable by the deploy user (${inspection.owner}:${inspection.group}, mode ${inspection.mode}), ` +
|
|
110
|
+
'but it is not group-writable. Web-created cache files may cause later permission drift.'
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
}
|
|
6
114
|
|
|
7
115
|
async function detectRemoteLaravelProject(ssh, remoteCwd) {
|
|
8
116
|
const laravelCheck = await ssh.execCommand(
|
|
@@ -134,7 +242,8 @@ function createMaintenanceModePlan({
|
|
|
134
242
|
async function resolveMaintenanceMode({
|
|
135
243
|
snapshot,
|
|
136
244
|
remoteIsLaravel,
|
|
137
|
-
runPrompt
|
|
245
|
+
runPrompt,
|
|
246
|
+
executionMode = {}
|
|
138
247
|
} = {}) {
|
|
139
248
|
if (!remoteIsLaravel) {
|
|
140
249
|
return false
|
|
@@ -144,6 +253,17 @@ async function resolveMaintenanceMode({
|
|
|
144
253
|
return snapshot.maintenanceModeEnabled
|
|
145
254
|
}
|
|
146
255
|
|
|
256
|
+
if (executionMode?.interactive === false) {
|
|
257
|
+
if (typeof executionMode.maintenanceMode !== 'boolean') {
|
|
258
|
+
throw new ZephyrError(
|
|
259
|
+
'Zephyr cannot run this Laravel deployment non-interactively without an explicit maintenance-mode decision. Pass --maintenance on or --maintenance off.',
|
|
260
|
+
{code: 'ZEPHYR_MAINTENANCE_FLAG_REQUIRED'}
|
|
261
|
+
)
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return executionMode.maintenanceMode
|
|
265
|
+
}
|
|
266
|
+
|
|
147
267
|
if (typeof runPrompt !== 'function') {
|
|
148
268
|
return false
|
|
149
269
|
}
|
|
@@ -222,6 +342,7 @@ export async function buildRemoteDeploymentPlan({
|
|
|
222
342
|
config,
|
|
223
343
|
snapshot = null,
|
|
224
344
|
requiredPhpVersion = null,
|
|
345
|
+
executionMode = {},
|
|
225
346
|
ssh,
|
|
226
347
|
remoteCwd,
|
|
227
348
|
executeRemote,
|
|
@@ -264,7 +385,8 @@ export async function buildRemoteDeploymentPlan({
|
|
|
264
385
|
const maintenanceModeEnabled = await resolveMaintenanceMode({
|
|
265
386
|
snapshot,
|
|
266
387
|
remoteIsLaravel,
|
|
267
|
-
runPrompt
|
|
388
|
+
runPrompt,
|
|
389
|
+
executionMode
|
|
268
390
|
})
|
|
269
391
|
|
|
270
392
|
const maintenanceModePlan = await resolveMaintenanceModePlan({
|
|
@@ -290,6 +412,15 @@ export async function buildRemoteDeploymentPlan({
|
|
|
290
412
|
maintenanceUpCommand: maintenanceModePlan.upCommand
|
|
291
413
|
})
|
|
292
414
|
|
|
415
|
+
await validateLaravelWritablePaths({
|
|
416
|
+
ssh,
|
|
417
|
+
remoteCwd,
|
|
418
|
+
sshUser: config.sshUser,
|
|
419
|
+
steps,
|
|
420
|
+
logProcessing,
|
|
421
|
+
logWarning
|
|
422
|
+
})
|
|
423
|
+
|
|
293
424
|
const usefulSteps = steps.length > 1
|
|
294
425
|
const pendingSnapshot = !usefulSteps
|
|
295
426
|
? null
|
|
@@ -320,4 +451,4 @@ export async function buildRemoteDeploymentPlan({
|
|
|
320
451
|
usefulSteps,
|
|
321
452
|
pendingSnapshot
|
|
322
453
|
}
|
|
323
|
-
}
|
|
454
|
+
}
|
|
@@ -46,7 +46,7 @@ export async function executeRemoteDeploymentPlan({
|
|
|
46
46
|
usefulSteps,
|
|
47
47
|
pendingSnapshot = null,
|
|
48
48
|
logProcessing,
|
|
49
|
-
executionState =
|
|
49
|
+
executionState = undefined
|
|
50
50
|
} = {}) {
|
|
51
51
|
if (usefulSteps && pendingSnapshot) {
|
|
52
52
|
await persistPendingSnapshot(rootDir, pendingSnapshot, executeRemote)
|
|
@@ -73,4 +73,4 @@ export async function executeRemoteDeploymentPlan({
|
|
|
73
73
|
await clearPendingTasksSnapshot(rootDir)
|
|
74
74
|
}
|
|
75
75
|
}
|
|
76
|
-
}
|
|
76
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import {clearPendingTasksSnapshot, loadPendingTasksSnapshot} from '../../deploy/snapshots.mjs'
|
|
2
|
+
import {ZephyrError} from '../../runtime/errors.mjs'
|
|
2
3
|
|
|
3
4
|
export async function resolvePendingSnapshot(rootDir, deploymentConfig, {
|
|
4
5
|
runPrompt,
|
|
5
6
|
logProcessing,
|
|
6
|
-
logWarning
|
|
7
|
+
logWarning,
|
|
8
|
+
executionMode = {}
|
|
7
9
|
} = {}) {
|
|
8
10
|
const existingSnapshot = await loadPendingTasksSnapshot(rootDir)
|
|
9
11
|
|
|
@@ -25,6 +27,24 @@ export async function resolvePendingSnapshot(rootDir, deploymentConfig, {
|
|
|
25
27
|
messageLines.push(`Tasks: ${existingSnapshot.taskLabels.join(', ')}`)
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
if (executionMode?.interactive === false) {
|
|
31
|
+
if (!executionMode.resumePending && !executionMode.discardPending) {
|
|
32
|
+
throw new ZephyrError(
|
|
33
|
+
'Zephyr found a pending deployment snapshot, but non-interactive mode requires either --resume-pending or --discard-pending.',
|
|
34
|
+
{code: 'ZEPHYR_PENDING_SNAPSHOT_ACTION_REQUIRED'}
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (executionMode.resumePending) {
|
|
39
|
+
logProcessing?.('Resuming deployment using saved task snapshot...')
|
|
40
|
+
return existingSnapshot
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await clearPendingTasksSnapshot(rootDir)
|
|
44
|
+
logWarning?.('Discarded pending deployment snapshot.')
|
|
45
|
+
return null
|
|
46
|
+
}
|
|
47
|
+
|
|
28
48
|
const {resumePendingTasks} = await runPrompt([
|
|
29
49
|
{
|
|
30
50
|
type: 'confirm',
|
|
@@ -26,7 +26,9 @@ async function maybeRecoverLaravelMaintenanceMode({
|
|
|
26
26
|
executionState,
|
|
27
27
|
executeRemote,
|
|
28
28
|
runPrompt,
|
|
29
|
-
|
|
29
|
+
logProcessing,
|
|
30
|
+
logWarning,
|
|
31
|
+
executionMode = {}
|
|
30
32
|
} = {}) {
|
|
31
33
|
if (!remotePlan?.remoteIsLaravel || !remotePlan?.maintenanceModeEnabled) {
|
|
32
34
|
return
|
|
@@ -36,12 +38,27 @@ async function maybeRecoverLaravelMaintenanceMode({
|
|
|
36
38
|
return
|
|
37
39
|
}
|
|
38
40
|
|
|
39
|
-
if (typeof
|
|
41
|
+
if (typeof executeRemote !== 'function') {
|
|
40
42
|
logWarning?.('Deployment failed while Laravel maintenance mode may still be enabled.')
|
|
41
43
|
return
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
try {
|
|
47
|
+
if (executionMode?.interactive === false) {
|
|
48
|
+
logProcessing?.('Deployment failed after Laravel maintenance mode was enabled. Running `artisan up` automatically...')
|
|
49
|
+
await executeRemote(
|
|
50
|
+
'Disable Laravel maintenance mode',
|
|
51
|
+
remotePlan.maintenanceUpCommand ?? `${remotePlan.phpCommand} artisan up`
|
|
52
|
+
)
|
|
53
|
+
executionState.exitedMaintenanceMode = true
|
|
54
|
+
return
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (typeof runPrompt !== 'function') {
|
|
58
|
+
logWarning?.('Deployment failed while Laravel maintenance mode may still be enabled.')
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
45
62
|
const answers = await runPrompt([
|
|
46
63
|
{
|
|
47
64
|
type: 'confirm',
|
|
@@ -82,7 +99,8 @@ export async function runDeployment(config, options = {}) {
|
|
|
82
99
|
logError,
|
|
83
100
|
runPrompt,
|
|
84
101
|
createSshClient,
|
|
85
|
-
runCommand
|
|
102
|
+
runCommand,
|
|
103
|
+
executionMode
|
|
86
104
|
} = context
|
|
87
105
|
|
|
88
106
|
await cleanupOldLogs(rootDir)
|
|
@@ -126,7 +144,11 @@ export async function runDeployment(config, options = {}) {
|
|
|
126
144
|
remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
|
|
127
145
|
|
|
128
146
|
logProcessing('Connection established. Acquiring deployment lock on server...')
|
|
129
|
-
await acquireRemoteLock(ssh, remoteCwd, rootDir, {
|
|
147
|
+
await acquireRemoteLock(ssh, remoteCwd, rootDir, {
|
|
148
|
+
runPrompt,
|
|
149
|
+
logWarning,
|
|
150
|
+
interactive: executionMode?.interactive !== false
|
|
151
|
+
})
|
|
130
152
|
lockAcquired = true
|
|
131
153
|
logProcessing(`Lock acquired. Running deployment commands in ${remoteCwd}...`)
|
|
132
154
|
|
|
@@ -145,6 +167,7 @@ export async function runDeployment(config, options = {}) {
|
|
|
145
167
|
snapshot,
|
|
146
168
|
rootDir,
|
|
147
169
|
requiredPhpVersion,
|
|
170
|
+
executionMode,
|
|
148
171
|
ssh,
|
|
149
172
|
remoteCwd,
|
|
150
173
|
executeRemote,
|
|
@@ -179,12 +202,18 @@ export async function runDeployment(config, options = {}) {
|
|
|
179
202
|
executionState,
|
|
180
203
|
executeRemote,
|
|
181
204
|
runPrompt,
|
|
182
|
-
|
|
205
|
+
logProcessing,
|
|
206
|
+
logWarning,
|
|
207
|
+
executionMode
|
|
183
208
|
})
|
|
184
209
|
|
|
185
210
|
if (lockAcquired && ssh && remoteCwd) {
|
|
186
211
|
try {
|
|
187
|
-
await compareLocksAndPrompt(rootDir, ssh, remoteCwd, {
|
|
212
|
+
await compareLocksAndPrompt(rootDir, ssh, remoteCwd, {
|
|
213
|
+
runPrompt,
|
|
214
|
+
logWarning,
|
|
215
|
+
interactive: executionMode?.interactive !== false
|
|
216
|
+
})
|
|
188
217
|
} catch {
|
|
189
218
|
// Ignore lock comparison errors during error handling
|
|
190
219
|
}
|
|
@@ -206,4 +235,4 @@ export async function runDeployment(config, options = {}) {
|
|
|
206
235
|
ssh.dispose()
|
|
207
236
|
}
|
|
208
237
|
}
|
|
209
|
-
}
|
|
238
|
+
}
|