@wyxos/zephyr 0.3.4 → 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 +17 -2
- 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
|
@@ -8,7 +8,7 @@ import {writeStderr} from '../../utils/output.mjs'
|
|
|
8
8
|
import {
|
|
9
9
|
ensureCleanWorkingTree,
|
|
10
10
|
ensureReleaseBranchReady,
|
|
11
|
-
runReleaseCommand
|
|
11
|
+
runReleaseCommand,
|
|
12
12
|
validateReleaseDependencies
|
|
13
13
|
} from '../../release/shared.mjs'
|
|
14
14
|
|
|
@@ -50,7 +50,13 @@ async function hasArtisan(rootDir = process.cwd()) {
|
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
async function runLint(skipLint, rootDir = process.cwd(), {
|
|
53
|
+
async function runLint(skipLint, rootDir = process.cwd(), {
|
|
54
|
+
logStep,
|
|
55
|
+
logSuccess,
|
|
56
|
+
logWarning,
|
|
57
|
+
runCommand = runReleaseCommand,
|
|
58
|
+
progressWriter = process.stdout
|
|
59
|
+
} = {}) {
|
|
54
60
|
if (skipLint) {
|
|
55
61
|
logWarning?.('Skipping lint because --skip-lint flag was provided.')
|
|
56
62
|
return
|
|
@@ -67,9 +73,9 @@ async function runLint(skipLint, rootDir = process.cwd(), {logStep, logSuccess,
|
|
|
67
73
|
|
|
68
74
|
let dotInterval = null
|
|
69
75
|
try {
|
|
70
|
-
|
|
76
|
+
progressWriter.write(' ')
|
|
71
77
|
dotInterval = setInterval(() => {
|
|
72
|
-
|
|
78
|
+
progressWriter.write('.')
|
|
73
79
|
}, 200)
|
|
74
80
|
|
|
75
81
|
await runCommand('php', [pintPath], {capture: true, cwd: rootDir})
|
|
@@ -78,14 +84,14 @@ async function runLint(skipLint, rootDir = process.cwd(), {logStep, logSuccess,
|
|
|
78
84
|
clearInterval(dotInterval)
|
|
79
85
|
dotInterval = null
|
|
80
86
|
}
|
|
81
|
-
|
|
87
|
+
progressWriter.write('\n')
|
|
82
88
|
logSuccess?.('Lint passed.')
|
|
83
89
|
} catch (error) {
|
|
84
90
|
if (dotInterval) {
|
|
85
91
|
clearInterval(dotInterval)
|
|
86
92
|
dotInterval = null
|
|
87
93
|
}
|
|
88
|
-
|
|
94
|
+
progressWriter.write('\n')
|
|
89
95
|
if (error.stdout) {
|
|
90
96
|
writeStderr(error.stdout)
|
|
91
97
|
}
|
|
@@ -96,7 +102,13 @@ async function runLint(skipLint, rootDir = process.cwd(), {logStep, logSuccess,
|
|
|
96
102
|
}
|
|
97
103
|
}
|
|
98
104
|
|
|
99
|
-
async function runTests(skipTests, composer, rootDir = process.cwd(), {
|
|
105
|
+
async function runTests(skipTests, composer, rootDir = process.cwd(), {
|
|
106
|
+
logStep,
|
|
107
|
+
logSuccess,
|
|
108
|
+
logWarning,
|
|
109
|
+
runCommand = runReleaseCommand,
|
|
110
|
+
progressWriter = process.stdout
|
|
111
|
+
} = {}) {
|
|
100
112
|
if (skipTests) {
|
|
101
113
|
logWarning?.('Skipping tests because --skip-tests flag was provided.')
|
|
102
114
|
return
|
|
@@ -114,9 +126,9 @@ async function runTests(skipTests, composer, rootDir = process.cwd(), {logStep,
|
|
|
114
126
|
|
|
115
127
|
let dotInterval = null
|
|
116
128
|
try {
|
|
117
|
-
|
|
129
|
+
progressWriter.write(' ')
|
|
118
130
|
dotInterval = setInterval(() => {
|
|
119
|
-
|
|
131
|
+
progressWriter.write('.')
|
|
120
132
|
}, 200)
|
|
121
133
|
|
|
122
134
|
if (hasArtisanFile) {
|
|
@@ -129,14 +141,14 @@ async function runTests(skipTests, composer, rootDir = process.cwd(), {logStep,
|
|
|
129
141
|
clearInterval(dotInterval)
|
|
130
142
|
dotInterval = null
|
|
131
143
|
}
|
|
132
|
-
|
|
144
|
+
progressWriter.write('\n')
|
|
133
145
|
logSuccess?.('Tests passed.')
|
|
134
146
|
} catch (error) {
|
|
135
147
|
if (dotInterval) {
|
|
136
148
|
clearInterval(dotInterval)
|
|
137
149
|
dotInterval = null
|
|
138
150
|
}
|
|
139
|
-
|
|
151
|
+
progressWriter.write('\n')
|
|
140
152
|
if (error.stdout) {
|
|
141
153
|
writeStderr(error.stdout)
|
|
142
154
|
}
|
|
@@ -147,7 +159,11 @@ async function runTests(skipTests, composer, rootDir = process.cwd(), {logStep,
|
|
|
147
159
|
}
|
|
148
160
|
}
|
|
149
161
|
|
|
150
|
-
async function bumpVersion(releaseType, rootDir = process.cwd(), {
|
|
162
|
+
async function bumpVersion(releaseType, rootDir = process.cwd(), {
|
|
163
|
+
logStep,
|
|
164
|
+
logSuccess,
|
|
165
|
+
runCommand = runReleaseCommand
|
|
166
|
+
} = {}) {
|
|
151
167
|
logStep?.('Bumping composer version...')
|
|
152
168
|
|
|
153
169
|
const composer = await readComposer(rootDir)
|
|
@@ -179,7 +195,11 @@ async function bumpVersion(releaseType, rootDir = process.cwd(), {logStep, logSu
|
|
|
179
195
|
return {...composer, version: newVersion}
|
|
180
196
|
}
|
|
181
197
|
|
|
182
|
-
async function pushChanges(rootDir = process.cwd(), {
|
|
198
|
+
async function pushChanges(rootDir = process.cwd(), {
|
|
199
|
+
logStep,
|
|
200
|
+
logSuccess,
|
|
201
|
+
runCommand = runReleaseCommand
|
|
202
|
+
} = {}) {
|
|
183
203
|
logStep?.('Pushing commits to origin...')
|
|
184
204
|
await runCommand('git', ['push'], {cwd: rootDir})
|
|
185
205
|
|
|
@@ -196,8 +216,19 @@ export async function releasePackagistPackage({
|
|
|
196
216
|
rootDir = process.cwd(),
|
|
197
217
|
logStep,
|
|
198
218
|
logSuccess,
|
|
199
|
-
logWarning
|
|
219
|
+
logWarning,
|
|
220
|
+
runPrompt,
|
|
221
|
+
runCommandImpl,
|
|
222
|
+
runCommandCaptureImpl,
|
|
223
|
+
interactive = true,
|
|
224
|
+
progressWriter = process.stdout
|
|
200
225
|
} = {}) {
|
|
226
|
+
const runCommand = (command, args, options = {}) => runReleaseCommand(command, args, {
|
|
227
|
+
...options,
|
|
228
|
+
runCommandImpl,
|
|
229
|
+
runCommandCaptureImpl
|
|
230
|
+
})
|
|
231
|
+
|
|
201
232
|
logStep?.('Reading composer metadata...')
|
|
202
233
|
const composer = await readComposer(rootDir)
|
|
203
234
|
|
|
@@ -206,17 +237,21 @@ export async function releasePackagistPackage({
|
|
|
206
237
|
}
|
|
207
238
|
|
|
208
239
|
logStep?.('Validating dependencies...')
|
|
209
|
-
await validateReleaseDependencies(rootDir, {
|
|
240
|
+
await validateReleaseDependencies(rootDir, {
|
|
241
|
+
prompt: runPrompt,
|
|
242
|
+
logSuccess,
|
|
243
|
+
interactive
|
|
244
|
+
})
|
|
210
245
|
|
|
211
246
|
logStep?.('Checking working tree status...')
|
|
212
247
|
await ensureCleanWorkingTree(rootDir, {runCommand})
|
|
213
248
|
await ensureReleaseBranchReady({rootDir, branchMethod: 'show-current', logStep, logWarning})
|
|
214
249
|
|
|
215
|
-
await runLint(skipLint, rootDir, {logStep, logSuccess, logWarning})
|
|
216
|
-
await runTests(skipTests, composer, rootDir, {logStep, logSuccess, logWarning})
|
|
250
|
+
await runLint(skipLint, rootDir, {logStep, logSuccess, logWarning, runCommand, progressWriter})
|
|
251
|
+
await runTests(skipTests, composer, rootDir, {logStep, logSuccess, logWarning, runCommand, progressWriter})
|
|
217
252
|
|
|
218
|
-
const updatedComposer = await bumpVersion(releaseType, rootDir, {logStep, logSuccess})
|
|
219
|
-
await pushChanges(rootDir, {logStep, logSuccess})
|
|
253
|
+
const updatedComposer = await bumpVersion(releaseType, rootDir, {logStep, logSuccess, runCommand})
|
|
254
|
+
await pushChanges(rootDir, {logStep, logSuccess, runCommand})
|
|
220
255
|
|
|
221
256
|
logSuccess?.(`Release workflow completed for ${composer.name}@${updatedComposer.version}.`)
|
|
222
257
|
logStep?.('Note: Packagist will automatically detect the new git tag and update the package.')
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
|
|
3
|
+
import {Command} from 'commander'
|
|
4
|
+
|
|
5
|
+
import {InvalidCliOptionsError} from '../runtime/errors.mjs'
|
|
6
|
+
|
|
7
|
+
const WORKFLOW_TYPES = new Set(['node', 'vue', 'packagist'])
|
|
8
|
+
function normalizeMaintenanceMode(value) {
|
|
9
|
+
if (value == null) {
|
|
10
|
+
return null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
if (value === 'on') {
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (value === 'off') {
|
|
18
|
+
return false
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
throw new InvalidCliOptionsError('Invalid value for --maintenance. Use "on" or "off".')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parseCliOptions(args = process.argv.slice(2)) {
|
|
25
|
+
const program = new Command()
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.allowExcessArguments(false)
|
|
29
|
+
.allowUnknownOption(false)
|
|
30
|
+
.exitOverride()
|
|
31
|
+
.option('--type <type>', 'Workflow type (node|vue|packagist). Omit for normal app deployments.')
|
|
32
|
+
.option('--non-interactive', 'Fail instead of prompting when Zephyr needs user input.')
|
|
33
|
+
.option('--json', 'Emit NDJSON events to stdout. Requires --non-interactive.')
|
|
34
|
+
.option('--preset <name>', 'Preset name to use for non-interactive app deployments.')
|
|
35
|
+
.option('--resume-pending', 'Resume a saved pending deployment snapshot without prompting.')
|
|
36
|
+
.option('--discard-pending', 'Discard a saved pending deployment snapshot without prompting.')
|
|
37
|
+
.option('--maintenance <mode>', 'Laravel maintenance mode policy for non-interactive app deploys (on|off).')
|
|
38
|
+
.option('--skip-tests', 'Skip test execution in package release workflows.')
|
|
39
|
+
.option('--skip-lint', 'Skip lint execution in package release workflows.')
|
|
40
|
+
.option('--skip-build', 'Skip build execution in node/vue release workflows.')
|
|
41
|
+
.option('--skip-deploy', 'Skip GitHub Pages deployment in node/vue release workflows.')
|
|
42
|
+
.argument(
|
|
43
|
+
'[version]',
|
|
44
|
+
'Version or npm bump type for deployments (e.g. 1.2.3, patch, minor, major).'
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
program.parse(args, {from: 'user'})
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new InvalidCliOptionsError(error.message)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const options = program.opts()
|
|
54
|
+
const workflowType = options.type ?? null
|
|
55
|
+
|
|
56
|
+
if (workflowType && !WORKFLOW_TYPES.has(workflowType)) {
|
|
57
|
+
throw new InvalidCliOptionsError('Invalid value for --type. Use one of: node, vue, packagist.')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
workflowType,
|
|
62
|
+
versionArg: program.args[0] ?? null,
|
|
63
|
+
nonInteractive: Boolean(options.nonInteractive),
|
|
64
|
+
json: Boolean(options.json),
|
|
65
|
+
presetName: options.preset ?? null,
|
|
66
|
+
resumePending: Boolean(options.resumePending),
|
|
67
|
+
discardPending: Boolean(options.discardPending),
|
|
68
|
+
maintenanceMode: normalizeMaintenanceMode(options.maintenance),
|
|
69
|
+
skipTests: Boolean(options.skipTests),
|
|
70
|
+
skipLint: Boolean(options.skipLint),
|
|
71
|
+
skipBuild: Boolean(options.skipBuild),
|
|
72
|
+
skipDeploy: Boolean(options.skipDeploy)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function validateCliOptions(options = {}) {
|
|
77
|
+
const {
|
|
78
|
+
workflowType = null,
|
|
79
|
+
nonInteractive = false,
|
|
80
|
+
json = false,
|
|
81
|
+
presetName = null,
|
|
82
|
+
resumePending = false,
|
|
83
|
+
discardPending = false,
|
|
84
|
+
maintenanceMode = null,
|
|
85
|
+
skipTests = false,
|
|
86
|
+
skipLint = false,
|
|
87
|
+
skipBuild = false,
|
|
88
|
+
skipDeploy = false
|
|
89
|
+
} = options
|
|
90
|
+
|
|
91
|
+
if (json && !nonInteractive) {
|
|
92
|
+
throw new InvalidCliOptionsError('--json requires --non-interactive.')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (resumePending && discardPending) {
|
|
96
|
+
throw new InvalidCliOptionsError('Use either --resume-pending or --discard-pending, not both.')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const isPackageRelease = workflowType === 'node' || workflowType === 'vue' || workflowType === 'packagist'
|
|
100
|
+
|
|
101
|
+
if (isPackageRelease) {
|
|
102
|
+
if (presetName) {
|
|
103
|
+
throw new InvalidCliOptionsError('--preset is only valid for app deployments.')
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (resumePending || discardPending) {
|
|
107
|
+
throw new InvalidCliOptionsError('--resume-pending and --discard-pending are only valid for app deployments.')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (maintenanceMode !== null) {
|
|
111
|
+
throw new InvalidCliOptionsError('--maintenance is only valid for app deployments.')
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
if (skipTests || skipLint || skipBuild || skipDeploy) {
|
|
115
|
+
throw new InvalidCliOptionsError('Release-only skip flags are not valid for app deployments.')
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (nonInteractive && !presetName) {
|
|
119
|
+
throw new InvalidCliOptionsError('--non-interactive app deployments require --preset <name>.')
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
package/src/config/project.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises'
|
|
2
2
|
|
|
3
|
+
import {ZephyrError} from '../runtime/errors.mjs'
|
|
3
4
|
import { ensureDirectory, getProjectConfigDir, getProjectConfigPath } from '../utils/paths.mjs'
|
|
4
5
|
import { generateId } from '../utils/id.mjs'
|
|
5
6
|
|
|
@@ -69,7 +70,12 @@ export function migratePresets(presets, apps) {
|
|
|
69
70
|
return { presets: migrated, needsMigration }
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
export async function loadProjectConfig(rootDir, servers = [], {
|
|
73
|
+
export async function loadProjectConfig(rootDir, servers = [], {
|
|
74
|
+
logSuccess,
|
|
75
|
+
logWarning,
|
|
76
|
+
strict = false,
|
|
77
|
+
allowMigration = true
|
|
78
|
+
} = {}) {
|
|
73
79
|
const configPath = getProjectConfigPath(rootDir)
|
|
74
80
|
|
|
75
81
|
try {
|
|
@@ -82,6 +88,13 @@ export async function loadProjectConfig(rootDir, servers = [], { logSuccess, log
|
|
|
82
88
|
const { presets: migratedPresets, needsMigration: presetsNeedMigration } = migratePresets(presets, migratedApps)
|
|
83
89
|
|
|
84
90
|
if (appsNeedMigration || presetsNeedMigration) {
|
|
91
|
+
if (!allowMigration) {
|
|
92
|
+
throw new ZephyrError(
|
|
93
|
+
'Zephyr cannot run non-interactively because .zephyr/config.json needs migration. Rerun interactively once to update the config.',
|
|
94
|
+
{code: 'ZEPHYR_PROJECT_CONFIG_MIGRATION_REQUIRED'}
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
|
|
85
98
|
await saveProjectConfig(rootDir, {
|
|
86
99
|
apps: migratedApps,
|
|
87
100
|
presets: migratedPresets
|
|
@@ -92,9 +105,27 @@ export async function loadProjectConfig(rootDir, servers = [], { logSuccess, log
|
|
|
92
105
|
return { apps: migratedApps, presets: migratedPresets }
|
|
93
106
|
} catch (error) {
|
|
94
107
|
if (error.code === 'ENOENT') {
|
|
108
|
+
if (strict) {
|
|
109
|
+
throw new ZephyrError(
|
|
110
|
+
'Zephyr cannot run non-interactively because .zephyr/config.json does not exist. Run an interactive deployment first to create it.',
|
|
111
|
+
{code: 'ZEPHYR_PROJECT_CONFIG_MISSING'}
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
95
115
|
return { apps: [], presets: [] }
|
|
96
116
|
}
|
|
97
117
|
|
|
118
|
+
if (error instanceof ZephyrError) {
|
|
119
|
+
throw error
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (strict) {
|
|
123
|
+
throw new ZephyrError(
|
|
124
|
+
'Zephyr cannot run non-interactively because .zephyr/config.json could not be read.',
|
|
125
|
+
{code: 'ZEPHYR_PROJECT_CONFIG_INVALID', cause: error}
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
98
129
|
logWarning?.('Failed to read .zephyr/config.json, starting with an empty list of apps.')
|
|
99
130
|
return { apps: [], presets: [] }
|
|
100
131
|
}
|
package/src/config/servers.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
|
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import path from 'node:path'
|
|
4
4
|
|
|
5
|
+
import {ZephyrError} from '../runtime/errors.mjs'
|
|
5
6
|
import { ensureDirectory } from '../utils/paths.mjs'
|
|
6
7
|
import { generateId } from '../utils/id.mjs'
|
|
7
8
|
|
|
@@ -25,7 +26,12 @@ export function migrateServers(servers) {
|
|
|
25
26
|
return { servers: migrated, needsMigration }
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
export async function loadServers({
|
|
29
|
+
export async function loadServers({
|
|
30
|
+
logSuccess,
|
|
31
|
+
logWarning,
|
|
32
|
+
strict = false,
|
|
33
|
+
allowMigration = true
|
|
34
|
+
} = {}) {
|
|
29
35
|
try {
|
|
30
36
|
const raw = await fs.readFile(SERVERS_FILE, 'utf8')
|
|
31
37
|
const data = JSON.parse(raw)
|
|
@@ -34,6 +40,13 @@ export async function loadServers({ logSuccess, logWarning } = {}) {
|
|
|
34
40
|
const { servers: migrated, needsMigration } = migrateServers(servers)
|
|
35
41
|
|
|
36
42
|
if (needsMigration) {
|
|
43
|
+
if (!allowMigration) {
|
|
44
|
+
throw new ZephyrError(
|
|
45
|
+
'Zephyr cannot run non-interactively because ~/.config/zephyr/servers.json needs migration. Rerun interactively once to update the config.',
|
|
46
|
+
{code: 'ZEPHYR_SERVERS_CONFIG_MIGRATION_REQUIRED'}
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
37
50
|
await saveServers(migrated)
|
|
38
51
|
logSuccess?.('Migrated servers configuration to use unique IDs.')
|
|
39
52
|
}
|
|
@@ -41,9 +54,27 @@ export async function loadServers({ logSuccess, logWarning } = {}) {
|
|
|
41
54
|
return migrated
|
|
42
55
|
} catch (error) {
|
|
43
56
|
if (error.code === 'ENOENT') {
|
|
57
|
+
if (strict) {
|
|
58
|
+
throw new ZephyrError(
|
|
59
|
+
'Zephyr cannot run non-interactively because ~/.config/zephyr/servers.json does not exist. Run an interactive deployment first to create it.',
|
|
60
|
+
{code: 'ZEPHYR_SERVERS_CONFIG_MISSING'}
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
44
64
|
return []
|
|
45
65
|
}
|
|
46
66
|
|
|
67
|
+
if (error instanceof ZephyrError) {
|
|
68
|
+
throw error
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (strict) {
|
|
72
|
+
throw new ZephyrError(
|
|
73
|
+
'Zephyr cannot run non-interactively because ~/.config/zephyr/servers.json could not be read.',
|
|
74
|
+
{code: 'ZEPHYR_SERVERS_CONFIG_INVALID', cause: error}
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
47
78
|
logWarning?.('Failed to read servers.json, starting with an empty list.')
|
|
48
79
|
return []
|
|
49
80
|
}
|
|
@@ -54,4 +85,3 @@ export async function saveServers(servers) {
|
|
|
54
85
|
const payload = JSON.stringify(servers, null, 2)
|
|
55
86
|
await fs.writeFile(SERVERS_FILE, `${payload}\n`)
|
|
56
87
|
}
|
|
57
|
-
|
|
@@ -2,6 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises'
|
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import process from 'node:process'
|
|
4
4
|
import chalk from 'chalk'
|
|
5
|
+
import {ZephyrError} from './runtime/errors.mjs'
|
|
5
6
|
import { runCommand as runCommandBase, runCommandCapture as runCommandCaptureBase } from './utils/command.mjs'
|
|
6
7
|
|
|
7
8
|
function isLocalPathOutsideRepo(depPath, rootDir) {
|
|
@@ -262,7 +263,9 @@ async function commitDependencyUpdates(rootDir, updatedFiles, promptFn, logFn) {
|
|
|
262
263
|
return true
|
|
263
264
|
}
|
|
264
265
|
|
|
265
|
-
async function validateLocalDependencies(rootDir, promptFn, logFn = null
|
|
266
|
+
async function validateLocalDependencies(rootDir, promptFn, logFn = null, {
|
|
267
|
+
interactive = true
|
|
268
|
+
} = {}) {
|
|
266
269
|
const packageDeps = await scanPackageJsonDependencies(rootDir)
|
|
267
270
|
const composerDeps = await scanComposerJsonDependencies(rootDir)
|
|
268
271
|
|
|
@@ -308,6 +311,13 @@ async function validateLocalDependencies(rootDir, promptFn, logFn = null) {
|
|
|
308
311
|
const countText = allDeps.length === 1 ? 'dependency' : 'dependencies'
|
|
309
312
|
const promptMessage = `Found ${countColored} local file ${countText} pointing outside the repository:\n\n${messages.join('\n\n')}\n\nUpdate to latest version?`
|
|
310
313
|
|
|
314
|
+
if (!interactive) {
|
|
315
|
+
throw new ZephyrError(
|
|
316
|
+
'Zephyr cannot run non-interactively because local file dependencies point outside the repository and require confirmation to update.',
|
|
317
|
+
{code: 'ZEPHYR_DEPENDENCY_UPDATE_REQUIRED'}
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
|
|
311
321
|
// Prompt user
|
|
312
322
|
const { shouldUpdate } = await promptFn([
|
|
313
323
|
{
|
package/src/deploy/locks.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import fs from 'node:fs/promises'
|
|
|
2
2
|
import os from 'node:os'
|
|
3
3
|
import process from 'node:process'
|
|
4
4
|
|
|
5
|
+
import {ZephyrError} from '../runtime/errors.mjs'
|
|
5
6
|
import { PROJECT_LOCK_FILE, ensureDirectory, getLockFilePath, getProjectConfigDir } from '../utils/paths.mjs'
|
|
6
7
|
|
|
7
8
|
function createLockPayload() {
|
|
@@ -67,7 +68,26 @@ export async function readRemoteLock(ssh, remoteCwd) {
|
|
|
67
68
|
return null
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
|
|
71
|
+
function parseLockDetails(rawContent = '') {
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(rawContent.trim())
|
|
74
|
+
} catch (_error) {
|
|
75
|
+
return { raw: rawContent.trim() }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatLockHolder(details = {}) {
|
|
80
|
+
const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
|
|
81
|
+
const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
|
|
82
|
+
|
|
83
|
+
return { startedBy, startedAt }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function compareLocksAndPrompt(rootDir, ssh, remoteCwd, {
|
|
87
|
+
runPrompt,
|
|
88
|
+
logWarning,
|
|
89
|
+
interactive = true
|
|
90
|
+
} = {}) {
|
|
71
91
|
const localLock = await readLocalLock(rootDir)
|
|
72
92
|
const remoteLock = await readRemoteLock(ssh, remoteCwd)
|
|
73
93
|
|
|
@@ -79,8 +99,15 @@ export async function compareLocksAndPrompt(rootDir, ssh, remoteCwd, { runPrompt
|
|
|
79
99
|
const remoteKey = `${remoteLock.user}@${remoteLock.hostname}:${remoteLock.pid}:${remoteLock.startedAt}`
|
|
80
100
|
|
|
81
101
|
if (localKey === remoteKey) {
|
|
82
|
-
const startedBy
|
|
83
|
-
|
|
102
|
+
const { startedBy, startedAt } = formatLockHolder(remoteLock)
|
|
103
|
+
|
|
104
|
+
if (!interactive) {
|
|
105
|
+
throw new ZephyrError(
|
|
106
|
+
`Stale deployment lock detected on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/.zephyr/${PROJECT_LOCK_FILE} manually before rerunning with --non-interactive.`,
|
|
107
|
+
{code: 'ZEPHYR_STALE_REMOTE_LOCK'}
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
84
111
|
const { shouldRemove } = await runPrompt([
|
|
85
112
|
{
|
|
86
113
|
type: 'confirm',
|
|
@@ -103,7 +130,11 @@ export async function compareLocksAndPrompt(rootDir, ssh, remoteCwd, { runPrompt
|
|
|
103
130
|
return false
|
|
104
131
|
}
|
|
105
132
|
|
|
106
|
-
export async function acquireRemoteLock(ssh, remoteCwd, rootDir, {
|
|
133
|
+
export async function acquireRemoteLock(ssh, remoteCwd, rootDir, {
|
|
134
|
+
runPrompt,
|
|
135
|
+
logWarning,
|
|
136
|
+
interactive = true
|
|
137
|
+
} = {}) {
|
|
107
138
|
const lockPath = `.zephyr/${PROJECT_LOCK_FILE}`
|
|
108
139
|
const escapedLockPath = lockPath.replace(/'/g, "'\\''")
|
|
109
140
|
const checkCommand = `mkdir -p .zephyr && if [ -f '${escapedLockPath}' ]; then cat '${escapedLockPath}'; else echo "LOCK_NOT_FOUND"; fi`
|
|
@@ -113,31 +144,17 @@ export async function acquireRemoteLock(ssh, remoteCwd, rootDir, { runPrompt, lo
|
|
|
113
144
|
if (checkResult.stdout && checkResult.stdout.trim() !== 'LOCK_NOT_FOUND' && checkResult.stdout.trim() !== '') {
|
|
114
145
|
const localLock = await readLocalLock(rootDir)
|
|
115
146
|
if (localLock) {
|
|
116
|
-
const removed = await compareLocksAndPrompt(rootDir, ssh, remoteCwd, { runPrompt, logWarning })
|
|
147
|
+
const removed = await compareLocksAndPrompt(rootDir, ssh, remoteCwd, { runPrompt, logWarning, interactive })
|
|
117
148
|
if (!removed) {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
details = JSON.parse(checkResult.stdout.trim())
|
|
121
|
-
} catch (_error) {
|
|
122
|
-
details = { raw: checkResult.stdout.trim() }
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
|
|
126
|
-
const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
|
|
149
|
+
const details = parseLockDetails(checkResult.stdout.trim())
|
|
150
|
+
const { startedBy, startedAt } = formatLockHolder(details)
|
|
127
151
|
throw new Error(
|
|
128
152
|
`Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/${lockPath} if you are sure it is stale.`
|
|
129
153
|
)
|
|
130
154
|
}
|
|
131
155
|
} else {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
details = JSON.parse(checkResult.stdout.trim())
|
|
135
|
-
} catch (_error) {
|
|
136
|
-
details = { raw: checkResult.stdout.trim() }
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
const startedBy = details.user ? `${details.user}@${details.hostname ?? 'unknown'}` : 'unknown user'
|
|
140
|
-
const startedAt = details.startedAt ? ` at ${details.startedAt}` : ''
|
|
156
|
+
const details = parseLockDetails(checkResult.stdout.trim())
|
|
157
|
+
const { startedBy, startedAt } = formatLockHolder(details)
|
|
141
158
|
throw new Error(
|
|
142
159
|
`Another deployment is currently in progress on the server (started by ${startedBy}${startedAt}). Remove ${remoteCwd}/${lockPath} if you are sure it is stale.`
|
|
143
160
|
)
|
|
@@ -168,4 +185,3 @@ export async function releaseRemoteLock(ssh, remoteCwd, { logWarning } = {}) {
|
|
|
168
185
|
logWarning?.(`Failed to remove lock file: ${result.stderr}`)
|
|
169
186
|
}
|
|
170
187
|
}
|
|
171
|
-
|