@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.
@@ -8,7 +8,7 @@ import {writeStderr} from '../../utils/output.mjs'
8
8
  import {
9
9
  ensureCleanWorkingTree,
10
10
  ensureReleaseBranchReady,
11
- runReleaseCommand as runCommand,
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(), {logStep, logSuccess, logWarning} = {}) {
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
- process.stdout.write(' ')
76
+ progressWriter.write(' ')
71
77
  dotInterval = setInterval(() => {
72
- process.stdout.write('.')
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
- process.stdout.write('\n')
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
- process.stdout.write('\n')
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(), {logStep, logSuccess, logWarning} = {}) {
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
- process.stdout.write(' ')
129
+ progressWriter.write(' ')
118
130
  dotInterval = setInterval(() => {
119
- process.stdout.write('.')
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
- process.stdout.write('\n')
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
- process.stdout.write('\n')
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(), {logStep, logSuccess} = {}) {
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(), {logStep, logSuccess} = {}) {
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, {logSuccess})
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
+ }
@@ -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 = [], { logSuccess, logWarning } = {}) {
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
  }
@@ -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({ logSuccess, logWarning } = {}) {
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
  {
@@ -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
- export async function compareLocksAndPrompt(rootDir, ssh, remoteCwd, { runPrompt, logWarning } = {}) {
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 = remoteLock.user ? `${remoteLock.user}@${remoteLock.hostname ?? 'unknown'}` : 'unknown user'
83
- const startedAt = remoteLock.startedAt ? ` at ${remoteLock.startedAt}` : ''
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, { runPrompt, logWarning } = {}) {
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
- let details = {}
119
- try {
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
- let details = {}
133
- try {
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
-