@wyxos/zephyr 0.2.21 → 0.2.22

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/src/main.mjs CHANGED
@@ -1,652 +1,677 @@
1
- import fs from 'node:fs/promises'
2
- import path from 'node:path'
3
- import os from 'node:os'
4
- import process from 'node:process'
5
- import chalk from 'chalk'
6
- import inquirer from 'inquirer'
7
- import { NodeSSH } from 'node-ssh'
8
- import { releaseNode } from './release-node.mjs'
9
- import { releasePackagist } from './release-packagist.mjs'
10
- import { validateLocalDependencies } from './dependency-scanner.mjs'
11
- import { checkAndUpdateVersion } from './version-checker.mjs'
12
- import { createChalkLogger, writeStderrLine, writeStdoutLine } from './utils/output.mjs'
13
- import { runCommand as runCommandBase, runCommandCapture as runCommandCaptureBase } from './utils/command.mjs'
14
- import { planLaravelDeploymentTasks } from './utils/task-planner.mjs'
15
- import {
16
- PENDING_TASKS_FILE,
17
- PROJECT_CONFIG_DIR
18
- } from './utils/paths.mjs'
19
- import { cleanupOldLogs, closeLogFile, getLogFilePath, writeToLogFile } from './utils/log-file.mjs'
20
- import {
21
- acquireRemoteLock,
22
- compareLocksAndPrompt,
23
- releaseLocalLock,
24
- releaseRemoteLock
25
- } from './deploy/locks.mjs'
26
- import {
27
- clearPendingTasksSnapshot,
28
- loadPendingTasksSnapshot,
29
- savePendingTasksSnapshot
30
- } from './deploy/snapshots.mjs'
31
- import * as bootstrap from './project/bootstrap.mjs'
32
- import * as preflight from './deploy/preflight.mjs'
33
- import * as sshKeys from './ssh/keys.mjs'
34
- import * as localRepo from './deploy/local-repo.mjs'
35
- import * as configFlow from './utils/config-flow.mjs'
36
- import { createRemoteExecutor } from './deploy/remote-exec.mjs'
37
- import { createRunPrompt } from './runtime/prompt.mjs'
38
- import { createSshClientFactory } from './runtime/ssh-client.mjs'
39
- import { createLocalCommandRunners } from './runtime/local-command.mjs'
40
- import { generateId } from './utils/id.mjs'
41
- import { loadServers, saveServers } from './config/servers.mjs'
42
- import { loadProjectConfig, saveProjectConfig } from './config/project.mjs'
43
- import { resolveRemotePath } from './utils/remote-path.mjs'
44
-
45
- const RELEASE_SCRIPT_NAME = 'release'
46
- const RELEASE_SCRIPT_COMMAND = 'npx @wyxos/zephyr@latest'
47
-
48
- const { logProcessing, logSuccess, logWarning, logError } = createChalkLogger(chalk)
49
-
50
- const runPrompt = createRunPrompt({ inquirer })
51
- const createSshClient = createSshClientFactory({ NodeSSH })
52
- const { runCommand, runCommandCapture } = createLocalCommandRunners({
53
- runCommandBase,
54
- runCommandCaptureBase
55
- })
56
-
57
- // Local repository state moved to src/deploy/local-repo.mjs
58
-
59
- async function getGitStatus(rootDir) {
60
- return await localRepo.getGitStatus(rootDir, { runCommandCapture })
61
- }
62
-
63
- async function hasUncommittedChanges(rootDir) {
64
- return await localRepo.hasUncommittedChanges(rootDir, { getGitStatus })
65
- }
66
-
67
- async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd()) {
68
- return await localRepo.ensureLocalRepositoryState(targetBranch, rootDir, {
69
- runPrompt,
70
- runCommand,
71
- runCommandCapture,
72
- logProcessing,
73
- logSuccess,
74
- logWarning
75
- })
76
- }
77
-
78
- async function ensureProjectReleaseScript(rootDir) {
79
- return await bootstrap.ensureProjectReleaseScript(rootDir, {
80
- runPrompt,
81
- runCommand,
82
- logSuccess,
83
- logWarning,
84
- releaseScriptName: RELEASE_SCRIPT_NAME,
85
- releaseScriptCommand: RELEASE_SCRIPT_COMMAND
86
- })
87
- }
88
-
89
- // Locks and snapshots moved to src/deploy/*
90
-
91
- async function ensureGitignoreEntry(rootDir) {
92
- return await bootstrap.ensureGitignoreEntry(rootDir, {
93
- projectConfigDir: PROJECT_CONFIG_DIR,
94
- runCommand,
95
- logSuccess,
96
- logWarning
97
- })
98
- }
99
-
100
- // Config storage/migrations moved to src/config/*
101
-
102
- function defaultProjectPath(currentDir) {
103
- return configFlow.defaultProjectPath(currentDir)
104
- }
105
-
106
- async function listGitBranches(currentDir) {
107
- return await configFlow.listGitBranches(currentDir, { runCommandCapture, logWarning })
108
- }
109
-
110
- async function promptSshDetails(currentDir, existing = {}) {
111
- return await sshKeys.promptSshDetails(currentDir, existing, { runPrompt })
112
- }
113
-
114
- async function ensureSshDetails(config, currentDir) {
115
- return await sshKeys.ensureSshDetails(config, currentDir, { runPrompt, logProcessing })
116
- }
117
-
118
- async function resolveSshKeyPath(targetPath) {
119
- return await sshKeys.resolveSshKeyPath(targetPath)
120
- }
121
-
122
- // resolveRemotePath moved to src/utils/remote-path.mjs
123
-
124
- async function runLinting(rootDir) {
125
- return await preflight.runLinting(rootDir, { runCommand, logProcessing, logSuccess })
126
- }
127
-
128
- async function commitLintingChanges(rootDir) {
129
- return await preflight.commitLintingChanges(rootDir, {
130
- getGitStatus,
131
- runCommand,
132
- logProcessing,
133
- logSuccess
134
- })
135
- }
136
-
137
- async function runRemoteTasks(config, options = {}) {
138
- const { snapshot = null, rootDir = process.cwd() } = options
139
-
140
- await cleanupOldLogs(rootDir)
141
- await ensureLocalRepositoryState(config.branch, rootDir)
142
-
143
- const isLaravel = await preflight.isLocalLaravelProject(rootDir)
144
- const hasHook = await preflight.hasPrePushHook(rootDir)
145
-
146
- if (!hasHook) {
147
- // Run linting before tests
148
- const lintRan = await runLinting(rootDir)
149
- if (lintRan) {
150
- // Check if linting made changes and commit them
151
- const hasChanges = await hasUncommittedChanges(rootDir)
152
- if (hasChanges) {
153
- await commitLintingChanges(rootDir)
154
- }
155
- }
156
-
157
- // Run tests for Laravel projects
158
- if (isLaravel) {
159
- logProcessing('Running Laravel tests locally...')
160
- try {
161
- await runCommand('php', ['artisan', 'test', '--compact'], { cwd: rootDir })
162
- logSuccess('Local tests passed.')
163
- } catch (error) {
164
- throw new Error(`Local tests failed. Fix test failures before deploying. ${error.message}`)
165
- }
166
- }
167
- } else {
168
- logProcessing('Pre-push git hook detected. Skipping local linting and test execution.')
169
- }
170
-
171
- const ssh = createSshClient()
172
- const sshUser = config.sshUser || os.userInfo().username
173
- const privateKeyPath = await resolveSshKeyPath(config.sshKey)
174
- const privateKey = await fs.readFile(privateKeyPath, 'utf8')
175
-
176
- logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
177
-
178
- let lockAcquired = false
179
-
180
- try {
181
- await ssh.connect({
182
- host: config.serverIp,
183
- username: sshUser,
184
- privateKey
185
- })
186
-
187
- const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
188
- const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
189
- const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
190
-
191
- logProcessing(`Connection established. Acquiring deployment lock on server...`)
192
- await acquireRemoteLock(ssh, remoteCwd, rootDir, { runPrompt, logWarning })
193
- lockAcquired = true
194
- logProcessing(`Lock acquired. Running deployment commands in ${remoteCwd}...`)
195
-
196
- const executeRemote = createRemoteExecutor({
197
- ssh,
198
- rootDir,
199
- remoteCwd,
200
- writeToLogFile,
201
- logProcessing,
202
- logSuccess,
203
- logError
204
- })
205
-
206
- const laravelCheck = await ssh.execCommand(
207
- 'if [ -f artisan ] && [ -f composer.json ] && grep -q "laravel/framework" composer.json; then echo "yes"; else echo "no"; fi',
208
- { cwd: remoteCwd }
209
- )
210
- const isLaravel = laravelCheck.stdout.trim() === 'yes'
211
-
212
- if (isLaravel) {
213
- logSuccess('Laravel project detected.')
214
- } else {
215
- logWarning('Laravel project not detected; skipping Laravel-specific maintenance tasks.')
216
- }
217
-
218
- let changedFiles = []
219
-
220
- if (snapshot && snapshot.changedFiles) {
221
- changedFiles = snapshot.changedFiles
222
- logProcessing('Resuming deployment with saved task snapshot.')
223
- } else if (isLaravel) {
224
- await executeRemote(`Fetch latest changes for ${config.branch}`, `git fetch origin ${config.branch}`)
225
-
226
- const diffResult = await executeRemote(
227
- 'Inspect pending changes',
228
- `git diff --name-only HEAD..origin/${config.branch}`,
229
- { printStdout: false }
230
- )
231
-
232
- changedFiles = diffResult.stdout
233
- .split(/\r?\n/)
234
- .map((line) => line.trim())
235
- .filter(Boolean)
236
-
237
- if (changedFiles.length > 0) {
238
- const preview = changedFiles
239
- .slice(0, 20)
240
- .map((file) => ` - ${file}`)
241
- .join('\n')
242
-
243
- logProcessing(
244
- `Detected ${changedFiles.length} changed file(s):\n${preview}${changedFiles.length > 20 ? '\n - ...' : ''
245
- }`
246
- )
247
- } else {
248
- logProcessing('No upstream file changes detected.')
249
- }
250
- }
251
-
252
- const hasPhpChanges = isLaravel && changedFiles.some((file) => file.endsWith('.php'))
253
-
254
- let horizonConfigured = false
255
- if (hasPhpChanges) {
256
- const horizonCheck = await ssh.execCommand(
257
- 'if [ -f config/horizon.php ]; then echo "yes"; else echo "no"; fi',
258
- { cwd: remoteCwd }
259
- )
260
- horizonConfigured = horizonCheck.stdout.trim() === 'yes'
261
- }
262
-
263
- const steps = planLaravelDeploymentTasks({
264
- branch: config.branch,
265
- isLaravel,
266
- changedFiles,
267
- horizonConfigured
268
- })
269
-
270
- const usefulSteps = steps.length > 1
271
-
272
- let pendingSnapshot
273
-
274
- if (usefulSteps) {
275
- pendingSnapshot = snapshot ?? {
276
- serverName: config.serverName,
277
- branch: config.branch,
278
- projectPath: config.projectPath,
279
- sshUser: config.sshUser,
280
- createdAt: new Date().toISOString(),
281
- changedFiles,
282
- taskLabels: steps.map((step) => step.label)
283
- }
284
-
285
- await savePendingTasksSnapshot(rootDir, pendingSnapshot)
286
-
287
- const payload = Buffer.from(JSON.stringify(pendingSnapshot)).toString('base64')
288
- await executeRemote(
289
- 'Record pending deployment tasks',
290
- `mkdir -p .zephyr && echo '${payload}' | base64 --decode > .zephyr/${PENDING_TASKS_FILE}`,
291
- { printStdout: false }
292
- )
293
- }
294
-
295
- if (steps.length === 1) {
296
- logProcessing('No additional maintenance tasks scheduled beyond git pull.')
297
- } else {
298
- const extraTasks = steps
299
- .slice(1)
300
- .map((step) => step.label)
301
- .join(', ')
302
-
303
- logProcessing(`Additional tasks scheduled: ${extraTasks}`)
304
- }
305
-
306
- let completed = false
307
-
308
- try {
309
- for (const step of steps) {
310
- await executeRemote(step.label, step.command)
311
- }
312
-
313
- completed = true
314
- } finally {
315
- if (usefulSteps && completed) {
316
- await executeRemote(
317
- 'Clear pending deployment snapshot',
318
- `rm -f .zephyr/${PENDING_TASKS_FILE}`,
319
- { printStdout: false, allowFailure: true }
320
- )
321
- await clearPendingTasksSnapshot(rootDir)
322
- }
323
- }
324
-
325
- logSuccess('\nDeployment commands completed successfully.')
326
-
327
- const logPath = await getLogFilePath(rootDir)
328
- logSuccess(`\nAll task output has been logged to: ${logPath}`)
329
- } catch (error) {
330
- const logPath = await getLogFilePath(rootDir).catch(() => null)
331
- if (logPath) {
332
- logError(`\nTask output has been logged to: ${logPath}`)
333
- }
334
-
335
- // If lock was acquired but deployment failed, check for stale locks
336
- if (lockAcquired && ssh) {
337
- try {
338
- const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
339
- const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
340
- const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
341
- await compareLocksAndPrompt(rootDir, ssh, remoteCwd, { runPrompt, logWarning })
342
- } catch (_lockError) {
343
- // Ignore lock comparison errors during error handling
344
- }
345
- }
346
-
347
- throw new Error(`Deployment failed: ${error.message}`)
348
- } finally {
349
- if (lockAcquired && ssh) {
350
- try {
351
- const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
352
- const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
353
- const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
354
- await releaseRemoteLock(ssh, remoteCwd, { logWarning })
355
- await releaseLocalLock(rootDir, { logWarning })
356
- } catch (error) {
357
- logWarning(`Failed to release lock: ${error.message}`)
358
- }
359
- }
360
- await closeLogFile()
361
- if (ssh) {
362
- ssh.dispose()
363
- }
364
- }
365
- }
366
-
367
- async function promptServerDetails(existingServers = []) {
368
- return await configFlow.promptServerDetails(existingServers, { runPrompt, generateId })
369
- }
370
-
371
- async function selectServer(servers) {
372
- return await configFlow.selectServer(servers, {
373
- runPrompt,
374
- logProcessing,
375
- logSuccess,
376
- saveServers,
377
- promptServerDetails
378
- })
379
- }
380
-
381
- async function promptAppDetails(currentDir, existing = {}) {
382
- return await configFlow.promptAppDetails(currentDir, existing, {
383
- runPrompt,
384
- listGitBranches,
385
- defaultProjectPath,
386
- promptSshDetails
387
- })
388
- }
389
-
390
- async function selectApp(projectConfig, server, currentDir) {
391
- return await configFlow.selectApp(projectConfig, server, currentDir, {
392
- runPrompt,
393
- logWarning,
394
- logProcessing,
395
- logSuccess,
396
- saveProjectConfig,
397
- generateId,
398
- promptAppDetails
399
- })
400
- }
401
-
402
- async function selectPreset(projectConfig, servers) {
403
- return await configFlow.selectPreset(projectConfig, servers, { runPrompt })
404
- }
405
-
406
- async function main(releaseType = null) {
407
- // Best-effort update check (skip during tests or when explicitly disabled)
408
- // If an update is accepted, the process will re-execute via npx @latest and we should exit early.
409
- if (
410
- process.env.ZEPHYR_SKIP_VERSION_CHECK !== '1' &&
411
- process.env.NODE_ENV !== 'test' &&
412
- process.env.VITEST !== 'true'
413
- ) {
414
- try {
415
- const args = process.argv.slice(2)
416
- const reExecuted = await checkAndUpdateVersion(runPrompt, args)
417
- if (reExecuted) {
418
- return
419
- }
420
- } catch (_error) {
421
- // Never block execution due to update check issues
422
- }
423
- }
424
-
425
- // Handle node/vue package release
426
- if (releaseType === 'node' || releaseType === 'vue') {
427
- try {
428
- await releaseNode()
429
- return
430
- } catch (error) {
431
- logError('\nRelease failed:')
432
- logError(error.message)
433
- if (error.stack) {
434
- writeStderrLine(error.stack)
435
- }
436
- process.exit(1)
437
- }
438
- }
439
-
440
- // Handle packagist/composer package release
441
- if (releaseType === 'packagist') {
442
- try {
443
- await releasePackagist()
444
- return
445
- } catch (error) {
446
- logError('\nRelease failed:')
447
- logError(error.message)
448
- if (error.stack) {
449
- writeStderrLine(error.stack)
450
- }
451
- process.exit(1)
452
- }
453
- }
454
-
455
- // Default: Laravel deployment workflow
456
- const rootDir = process.cwd()
457
-
458
- await ensureGitignoreEntry(rootDir)
459
- await ensureProjectReleaseScript(rootDir)
460
-
461
- // Validate dependencies if package.json or composer.json exists
462
- const packageJsonPath = path.join(rootDir, 'package.json')
463
- const composerJsonPath = path.join(rootDir, 'composer.json')
464
- const hasPackageJson = await fs.access(packageJsonPath).then(() => true).catch(() => false)
465
- const hasComposerJson = await fs.access(composerJsonPath).then(() => true).catch(() => false)
466
-
467
- if (hasPackageJson || hasComposerJson) {
468
- logProcessing('Validating dependencies...')
469
- await validateLocalDependencies(rootDir, runPrompt, logSuccess)
470
- }
471
-
472
- // Load servers first (they may be migrated)
473
- const servers = await loadServers({ logSuccess, logWarning })
474
- // Load project config with servers for migration
475
- const projectConfig = await loadProjectConfig(rootDir, servers, { logSuccess, logWarning })
476
-
477
- let server = null
478
- let appConfig = null
479
- let isCreatingNewPreset = false
480
-
481
- const preset = await selectPreset(projectConfig, servers)
482
-
483
- if (preset === 'create') {
484
- // User explicitly chose to create a new preset
485
- isCreatingNewPreset = true
486
- server = await selectServer(servers)
487
- appConfig = await selectApp(projectConfig, server, rootDir)
488
- } else if (preset) {
489
- // User selected an existing preset - look up by appId
490
- if (preset.appId) {
491
- appConfig = projectConfig.apps?.find((a) => a.id === preset.appId)
492
-
493
- if (!appConfig) {
494
- logWarning(`Preset references app configuration that no longer exists. Creating new configuration.`)
495
- server = await selectServer(servers)
496
- appConfig = await selectApp(projectConfig, server, rootDir)
497
- } else {
498
- server = servers.find((s) => s.id === appConfig.serverId || s.serverName === appConfig.serverName)
499
-
500
- if (!server) {
501
- logWarning(`Preset references server that no longer exists. Creating new configuration.`)
502
- server = await selectServer(servers)
503
- appConfig = await selectApp(projectConfig, server, rootDir)
504
- } else if (preset.branch && appConfig.branch !== preset.branch) {
505
- // Update branch if preset has a different branch
506
- appConfig.branch = preset.branch
507
- await saveProjectConfig(rootDir, projectConfig)
508
- logSuccess(`Updated branch to ${preset.branch} from preset.`)
509
- }
510
- }
511
- } else if (preset.key) {
512
- // Legacy preset format - migrate it
513
- const keyParts = preset.key.split(':')
514
- const serverName = keyParts[0]
515
- const projectPath = keyParts[1]
516
- const presetBranch = preset.branch || (keyParts.length === 3 ? keyParts[2] : null)
517
-
518
- server = servers.find((s) => s.serverName === serverName)
519
-
520
- if (!server) {
521
- logWarning(`Preset references server "${serverName}" which no longer exists. Creating new configuration.`)
522
- server = await selectServer(servers)
523
- appConfig = await selectApp(projectConfig, server, rootDir)
524
- } else {
525
- appConfig = projectConfig.apps?.find(
526
- (a) => (a.serverId === server.id || a.serverName === serverName) && a.projectPath === projectPath
527
- )
528
-
529
- if (!appConfig) {
530
- logWarning(`Preset references app configuration that no longer exists. Creating new configuration.`)
531
- appConfig = await selectApp(projectConfig, server, rootDir)
532
- } else {
533
- // Migrate preset to use appId
534
- preset.appId = appConfig.id
535
- if (presetBranch && appConfig.branch !== presetBranch) {
536
- appConfig.branch = presetBranch
537
- }
538
- preset.branch = appConfig.branch
539
- await saveProjectConfig(rootDir, projectConfig)
540
- }
541
- }
542
- } else {
543
- logWarning(`Preset format is invalid. Creating new configuration.`)
544
- server = await selectServer(servers)
545
- appConfig = await selectApp(projectConfig, server, rootDir)
546
- }
547
- } else {
548
- // No presets exist, go through normal flow
549
- server = await selectServer(servers)
550
- appConfig = await selectApp(projectConfig, server, rootDir)
551
- }
552
-
553
- const updated = await ensureSshDetails(appConfig, rootDir)
554
-
555
- if (updated) {
556
- await saveProjectConfig(rootDir, projectConfig)
557
- logSuccess('Updated .zephyr/config.json with SSH details.')
558
- }
559
-
560
- const deploymentConfig = {
561
- serverName: server.serverName,
562
- serverIp: server.serverIp,
563
- projectPath: appConfig.projectPath,
564
- branch: appConfig.branch,
565
- sshUser: appConfig.sshUser,
566
- sshKey: appConfig.sshKey
567
- }
568
-
569
- logProcessing('\nSelected deployment target:')
570
- writeStdoutLine(JSON.stringify(deploymentConfig, null, 2))
571
-
572
- if (isCreatingNewPreset || !preset) {
573
- const { presetName } = await runPrompt([
574
- {
575
- type: 'input',
576
- name: 'presetName',
577
- message: 'Enter a name for this preset (leave blank to skip)',
578
- default: isCreatingNewPreset ? '' : undefined
579
- }
580
- ])
581
-
582
- const trimmedName = presetName?.trim()
583
-
584
- if (trimmedName && trimmedName.length > 0) {
585
- const presets = projectConfig.presets ?? []
586
-
587
- // Find app config to get its ID
588
- const appId = appConfig.id
589
-
590
- if (!appId) {
591
- logWarning('Cannot save preset: app configuration missing ID.')
592
- } else {
593
- // Check if preset with this appId already exists
594
- const existingIndex = presets.findIndex((p) => p.appId === appId)
595
- if (existingIndex >= 0) {
596
- presets[existingIndex].name = trimmedName
597
- presets[existingIndex].branch = deploymentConfig.branch
598
- } else {
599
- presets.push({
600
- name: trimmedName,
601
- appId: appId,
602
- branch: deploymentConfig.branch
603
- })
604
- }
605
-
606
- projectConfig.presets = presets
607
- await saveProjectConfig(rootDir, projectConfig)
608
- logSuccess(`Saved preset "${trimmedName}" to .zephyr/config.json`)
609
- }
610
- }
611
- }
612
-
613
- const existingSnapshot = await loadPendingTasksSnapshot(rootDir)
614
- let snapshotToUse = null
615
-
616
- if (existingSnapshot) {
617
- const matchesSelection =
618
- existingSnapshot.serverName === deploymentConfig.serverName &&
619
- existingSnapshot.branch === deploymentConfig.branch
620
-
621
- const messageLines = [
622
- 'Pending deployment tasks were detected from a previous run.',
623
- `Server: ${existingSnapshot.serverName}`,
624
- `Branch: ${existingSnapshot.branch}`
625
- ]
626
-
627
- if (existingSnapshot.taskLabels && existingSnapshot.taskLabels.length > 0) {
628
- messageLines.push(`Tasks: ${existingSnapshot.taskLabels.join(', ')}`)
629
- }
630
-
631
- const { resumePendingTasks } = await runPrompt([
632
- {
633
- type: 'confirm',
634
- name: 'resumePendingTasks',
635
- message: `${messageLines.join(' | ')}. Resume using this plan?`,
636
- default: matchesSelection
637
- }
638
- ])
639
-
640
- if (resumePendingTasks) {
641
- snapshotToUse = existingSnapshot
642
- logProcessing('Resuming deployment using saved task snapshot...')
643
- } else {
644
- await clearPendingTasksSnapshot(rootDir)
645
- logWarning('Discarded pending deployment snapshot.')
646
- }
647
- }
648
-
649
- await runRemoteTasks(deploymentConfig, { rootDir, snapshot: snapshotToUse })
650
- }
651
-
652
- export { main, runRemoteTasks }
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import process from 'node:process'
5
+ import chalk from 'chalk'
6
+ import inquirer from 'inquirer'
7
+ import { NodeSSH } from 'node-ssh'
8
+ import { releaseNode } from './release-node.mjs'
9
+ import { releasePackagist } from './release-packagist.mjs'
10
+ import { validateLocalDependencies } from './dependency-scanner.mjs'
11
+ import { checkAndUpdateVersion } from './version-checker.mjs'
12
+ import { createChalkLogger, writeStderrLine, writeStdoutLine } from './utils/output.mjs'
13
+ import { runCommand as runCommandBase, runCommandCapture as runCommandCaptureBase } from './utils/command.mjs'
14
+ import { planLaravelDeploymentTasks } from './utils/task-planner.mjs'
15
+ import { getPhpVersionRequirement, findPhpBinary } from './utils/php-version.mjs'
16
+ import {
17
+ PENDING_TASKS_FILE,
18
+ PROJECT_CONFIG_DIR
19
+ } from './utils/paths.mjs'
20
+ import { cleanupOldLogs, closeLogFile, getLogFilePath, writeToLogFile } from './utils/log-file.mjs'
21
+ import {
22
+ acquireRemoteLock,
23
+ compareLocksAndPrompt,
24
+ releaseLocalLock,
25
+ releaseRemoteLock
26
+ } from './deploy/locks.mjs'
27
+ import {
28
+ clearPendingTasksSnapshot,
29
+ loadPendingTasksSnapshot,
30
+ savePendingTasksSnapshot
31
+ } from './deploy/snapshots.mjs'
32
+ import * as bootstrap from './project/bootstrap.mjs'
33
+ import * as preflight from './deploy/preflight.mjs'
34
+ import * as sshKeys from './ssh/keys.mjs'
35
+ import * as localRepo from './deploy/local-repo.mjs'
36
+ import * as configFlow from './utils/config-flow.mjs'
37
+ import { createRemoteExecutor } from './deploy/remote-exec.mjs'
38
+ import { createRunPrompt } from './runtime/prompt.mjs'
39
+ import { createSshClientFactory } from './runtime/ssh-client.mjs'
40
+ import { createLocalCommandRunners } from './runtime/local-command.mjs'
41
+ import { generateId } from './utils/id.mjs'
42
+ import { loadServers, saveServers } from './config/servers.mjs'
43
+ import { loadProjectConfig, saveProjectConfig } from './config/project.mjs'
44
+ import { resolveRemotePath } from './utils/remote-path.mjs'
45
+
46
+ const RELEASE_SCRIPT_NAME = 'release'
47
+ const RELEASE_SCRIPT_COMMAND = 'npx @wyxos/zephyr@latest'
48
+
49
+ const { logProcessing, logSuccess, logWarning, logError } = createChalkLogger(chalk)
50
+
51
+ const runPrompt = createRunPrompt({ inquirer })
52
+ const createSshClient = createSshClientFactory({ NodeSSH })
53
+ const { runCommand, runCommandCapture } = createLocalCommandRunners({
54
+ runCommandBase,
55
+ runCommandCaptureBase
56
+ })
57
+
58
+ // Local repository state moved to src/deploy/local-repo.mjs
59
+
60
+ async function getGitStatus(rootDir) {
61
+ return await localRepo.getGitStatus(rootDir, { runCommandCapture })
62
+ }
63
+
64
+ async function hasUncommittedChanges(rootDir) {
65
+ return await localRepo.hasUncommittedChanges(rootDir, { getGitStatus })
66
+ }
67
+
68
+ async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd()) {
69
+ return await localRepo.ensureLocalRepositoryState(targetBranch, rootDir, {
70
+ runPrompt,
71
+ runCommand,
72
+ runCommandCapture,
73
+ logProcessing,
74
+ logSuccess,
75
+ logWarning
76
+ })
77
+ }
78
+
79
+ async function ensureProjectReleaseScript(rootDir) {
80
+ return await bootstrap.ensureProjectReleaseScript(rootDir, {
81
+ runPrompt,
82
+ runCommand,
83
+ logSuccess,
84
+ logWarning,
85
+ releaseScriptName: RELEASE_SCRIPT_NAME,
86
+ releaseScriptCommand: RELEASE_SCRIPT_COMMAND
87
+ })
88
+ }
89
+
90
+ // Locks and snapshots moved to src/deploy/*
91
+
92
+ async function ensureGitignoreEntry(rootDir) {
93
+ return await bootstrap.ensureGitignoreEntry(rootDir, {
94
+ projectConfigDir: PROJECT_CONFIG_DIR,
95
+ runCommand,
96
+ logSuccess,
97
+ logWarning
98
+ })
99
+ }
100
+
101
+ // Config storage/migrations moved to src/config/*
102
+
103
+ function defaultProjectPath(currentDir) {
104
+ return configFlow.defaultProjectPath(currentDir)
105
+ }
106
+
107
+ async function listGitBranches(currentDir) {
108
+ return await configFlow.listGitBranches(currentDir, { runCommandCapture, logWarning })
109
+ }
110
+
111
+ async function promptSshDetails(currentDir, existing = {}) {
112
+ return await sshKeys.promptSshDetails(currentDir, existing, { runPrompt })
113
+ }
114
+
115
+ async function ensureSshDetails(config, currentDir) {
116
+ return await sshKeys.ensureSshDetails(config, currentDir, { runPrompt, logProcessing })
117
+ }
118
+
119
+ async function resolveSshKeyPath(targetPath) {
120
+ return await sshKeys.resolveSshKeyPath(targetPath)
121
+ }
122
+
123
+ // resolveRemotePath moved to src/utils/remote-path.mjs
124
+
125
+ async function runLinting(rootDir) {
126
+ return await preflight.runLinting(rootDir, { runCommand, logProcessing, logSuccess })
127
+ }
128
+
129
+ async function commitLintingChanges(rootDir) {
130
+ return await preflight.commitLintingChanges(rootDir, {
131
+ getGitStatus,
132
+ runCommand,
133
+ logProcessing,
134
+ logSuccess
135
+ })
136
+ }
137
+
138
+ async function runRemoteTasks(config, options = {}) {
139
+ const { snapshot = null, rootDir = process.cwd() } = options
140
+
141
+ await cleanupOldLogs(rootDir)
142
+ await ensureLocalRepositoryState(config.branch, rootDir)
143
+
144
+ // Detect PHP version requirement from local composer.json
145
+ let requiredPhpVersion = null
146
+ try {
147
+ requiredPhpVersion = await getPhpVersionRequirement(rootDir)
148
+ } catch {
149
+ // Ignore - composer.json might not exist or be unreadable
150
+ }
151
+
152
+ const isLaravel = await preflight.isLocalLaravelProject(rootDir)
153
+ const hasHook = await preflight.hasPrePushHook(rootDir)
154
+
155
+ if (!hasHook) {
156
+ // Run linting before tests
157
+ const lintRan = await runLinting(rootDir)
158
+ if (lintRan) {
159
+ // Check if linting made changes and commit them
160
+ const hasChanges = await hasUncommittedChanges(rootDir)
161
+ if (hasChanges) {
162
+ await commitLintingChanges(rootDir)
163
+ }
164
+ }
165
+
166
+ // Run tests for Laravel projects
167
+ if (isLaravel) {
168
+ logProcessing('Running Laravel tests locally...')
169
+ try {
170
+ await runCommand('php', ['artisan', 'test', '--compact'], { cwd: rootDir })
171
+ logSuccess('Local tests passed.')
172
+ } catch (error) {
173
+ throw new Error(`Local tests failed. Fix test failures before deploying. ${error.message}`)
174
+ }
175
+ }
176
+ } else {
177
+ logProcessing('Pre-push git hook detected. Skipping local linting and test execution.')
178
+ }
179
+
180
+ const ssh = createSshClient()
181
+ const sshUser = config.sshUser || os.userInfo().username
182
+ const privateKeyPath = await resolveSshKeyPath(config.sshKey)
183
+ const privateKey = await fs.readFile(privateKeyPath, 'utf8')
184
+
185
+ logProcessing(`\nConnecting to ${config.serverIp} as ${sshUser}...`)
186
+
187
+ let lockAcquired = false
188
+
189
+ try {
190
+ await ssh.connect({
191
+ host: config.serverIp,
192
+ username: sshUser,
193
+ privateKey
194
+ })
195
+
196
+ const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
197
+ const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
198
+ const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
199
+
200
+ logProcessing(`Connection established. Acquiring deployment lock on server...`)
201
+ await acquireRemoteLock(ssh, remoteCwd, rootDir, { runPrompt, logWarning })
202
+ lockAcquired = true
203
+ logProcessing(`Lock acquired. Running deployment commands in ${remoteCwd}...`)
204
+
205
+ const executeRemote = createRemoteExecutor({
206
+ ssh,
207
+ rootDir,
208
+ remoteCwd,
209
+ writeToLogFile,
210
+ logProcessing,
211
+ logSuccess,
212
+ logError
213
+ })
214
+
215
+ const laravelCheck = await ssh.execCommand(
216
+ 'if [ -f artisan ] && [ -f composer.json ] && grep -q "laravel/framework" composer.json; then echo "yes"; else echo "no"; fi',
217
+ { cwd: remoteCwd }
218
+ )
219
+ const isLaravel = laravelCheck.stdout.trim() === 'yes'
220
+
221
+ if (isLaravel) {
222
+ logSuccess('Laravel project detected.')
223
+ } else {
224
+ logWarning('Laravel project not detected; skipping Laravel-specific maintenance tasks.')
225
+ }
226
+
227
+ let changedFiles = []
228
+
229
+ if (snapshot && snapshot.changedFiles) {
230
+ changedFiles = snapshot.changedFiles
231
+ logProcessing('Resuming deployment with saved task snapshot.')
232
+ } else if (isLaravel) {
233
+ await executeRemote(`Fetch latest changes for ${config.branch}`, `git fetch origin ${config.branch}`)
234
+
235
+ const diffResult = await executeRemote(
236
+ 'Inspect pending changes',
237
+ `git diff --name-only HEAD..origin/${config.branch}`,
238
+ { printStdout: false }
239
+ )
240
+
241
+ changedFiles = diffResult.stdout
242
+ .split(/\r?\n/)
243
+ .map((line) => line.trim())
244
+ .filter(Boolean)
245
+
246
+ if (changedFiles.length > 0) {
247
+ const preview = changedFiles
248
+ .slice(0, 20)
249
+ .map((file) => ` - ${file}`)
250
+ .join('\n')
251
+
252
+ logProcessing(
253
+ `Detected ${changedFiles.length} changed file(s):\n${preview}${changedFiles.length > 20 ? '\n - ...' : ''
254
+ }`
255
+ )
256
+ } else {
257
+ logProcessing('No upstream file changes detected.')
258
+ }
259
+ }
260
+
261
+ const hasPhpChanges = isLaravel && changedFiles.some((file) => file.endsWith('.php'))
262
+
263
+ let horizonConfigured = false
264
+ if (hasPhpChanges) {
265
+ const horizonCheck = await ssh.execCommand(
266
+ 'if [ -f config/horizon.php ]; then echo "yes"; else echo "no"; fi',
267
+ { cwd: remoteCwd }
268
+ )
269
+ horizonConfigured = horizonCheck.stdout.trim() === 'yes'
270
+ }
271
+
272
+ // Find the appropriate PHP binary based on local composer.json requirement
273
+ let phpCommand = 'php'
274
+ if (requiredPhpVersion) {
275
+ try {
276
+ phpCommand = await findPhpBinary(ssh, remoteCwd, requiredPhpVersion)
277
+
278
+ if (phpCommand !== 'php') {
279
+ logProcessing(`Detected PHP requirement: ${requiredPhpVersion}, using ${phpCommand}`)
280
+ }
281
+ } catch (error) {
282
+ // If we can't find the PHP binary, fall back to default 'php'
283
+ logWarning(`Could not find PHP binary for version ${requiredPhpVersion}: ${error.message}`)
284
+ }
285
+ }
286
+
287
+ const steps = planLaravelDeploymentTasks({
288
+ branch: config.branch,
289
+ isLaravel,
290
+ changedFiles,
291
+ horizonConfigured,
292
+ phpCommand
293
+ })
294
+
295
+ const usefulSteps = steps.length > 1
296
+
297
+ let pendingSnapshot
298
+
299
+ if (usefulSteps) {
300
+ pendingSnapshot = snapshot ?? {
301
+ serverName: config.serverName,
302
+ branch: config.branch,
303
+ projectPath: config.projectPath,
304
+ sshUser: config.sshUser,
305
+ createdAt: new Date().toISOString(),
306
+ changedFiles,
307
+ taskLabels: steps.map((step) => step.label)
308
+ }
309
+
310
+ await savePendingTasksSnapshot(rootDir, pendingSnapshot)
311
+
312
+ const payload = Buffer.from(JSON.stringify(pendingSnapshot)).toString('base64')
313
+ await executeRemote(
314
+ 'Record pending deployment tasks',
315
+ `mkdir -p .zephyr && echo '${payload}' | base64 --decode > .zephyr/${PENDING_TASKS_FILE}`,
316
+ { printStdout: false }
317
+ )
318
+ }
319
+
320
+ if (steps.length === 1) {
321
+ logProcessing('No additional maintenance tasks scheduled beyond git pull.')
322
+ } else {
323
+ const extraTasks = steps
324
+ .slice(1)
325
+ .map((step) => step.label)
326
+ .join(', ')
327
+
328
+ logProcessing(`Additional tasks scheduled: ${extraTasks}`)
329
+ }
330
+
331
+ let completed = false
332
+
333
+ try {
334
+ for (const step of steps) {
335
+ await executeRemote(step.label, step.command)
336
+ }
337
+
338
+ completed = true
339
+ } finally {
340
+ if (usefulSteps && completed) {
341
+ await executeRemote(
342
+ 'Clear pending deployment snapshot',
343
+ `rm -f .zephyr/${PENDING_TASKS_FILE}`,
344
+ { printStdout: false, allowFailure: true }
345
+ )
346
+ await clearPendingTasksSnapshot(rootDir)
347
+ }
348
+ }
349
+
350
+ logSuccess('\nDeployment commands completed successfully.')
351
+
352
+ const logPath = await getLogFilePath(rootDir)
353
+ logSuccess(`\nAll task output has been logged to: ${logPath}`)
354
+ } catch (error) {
355
+ const logPath = await getLogFilePath(rootDir).catch(() => null)
356
+ if (logPath) {
357
+ logError(`\nTask output has been logged to: ${logPath}`)
358
+ }
359
+
360
+ // If lock was acquired but deployment failed, check for stale locks
361
+ if (lockAcquired && ssh) {
362
+ try {
363
+ const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
364
+ const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
365
+ const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
366
+ await compareLocksAndPrompt(rootDir, ssh, remoteCwd, { runPrompt, logWarning })
367
+ } catch (_lockError) {
368
+ // Ignore lock comparison errors during error handling
369
+ }
370
+ }
371
+
372
+ throw new Error(`Deployment failed: ${error.message}`)
373
+ } finally {
374
+ if (lockAcquired && ssh) {
375
+ try {
376
+ const remoteHomeResult = await ssh.execCommand('printf "%s" "$HOME"')
377
+ const remoteHome = remoteHomeResult.stdout.trim() || `/home/${sshUser}`
378
+ const remoteCwd = resolveRemotePath(config.projectPath, remoteHome)
379
+ await releaseRemoteLock(ssh, remoteCwd, { logWarning })
380
+ await releaseLocalLock(rootDir, { logWarning })
381
+ } catch (error) {
382
+ logWarning(`Failed to release lock: ${error.message}`)
383
+ }
384
+ }
385
+ await closeLogFile()
386
+ if (ssh) {
387
+ ssh.dispose()
388
+ }
389
+ }
390
+ }
391
+
392
+ async function promptServerDetails(existingServers = []) {
393
+ return await configFlow.promptServerDetails(existingServers, { runPrompt, generateId })
394
+ }
395
+
396
+ async function selectServer(servers) {
397
+ return await configFlow.selectServer(servers, {
398
+ runPrompt,
399
+ logProcessing,
400
+ logSuccess,
401
+ saveServers,
402
+ promptServerDetails
403
+ })
404
+ }
405
+
406
+ async function promptAppDetails(currentDir, existing = {}) {
407
+ return await configFlow.promptAppDetails(currentDir, existing, {
408
+ runPrompt,
409
+ listGitBranches,
410
+ defaultProjectPath,
411
+ promptSshDetails
412
+ })
413
+ }
414
+
415
+ async function selectApp(projectConfig, server, currentDir) {
416
+ return await configFlow.selectApp(projectConfig, server, currentDir, {
417
+ runPrompt,
418
+ logWarning,
419
+ logProcessing,
420
+ logSuccess,
421
+ saveProjectConfig,
422
+ generateId,
423
+ promptAppDetails
424
+ })
425
+ }
426
+
427
+ async function selectPreset(projectConfig, servers) {
428
+ return await configFlow.selectPreset(projectConfig, servers, { runPrompt })
429
+ }
430
+
431
+ async function main(releaseType = null) {
432
+ // Best-effort update check (skip during tests or when explicitly disabled)
433
+ // If an update is accepted, the process will re-execute via npx @latest and we should exit early.
434
+ if (
435
+ process.env.ZEPHYR_SKIP_VERSION_CHECK !== '1' &&
436
+ process.env.NODE_ENV !== 'test' &&
437
+ process.env.VITEST !== 'true'
438
+ ) {
439
+ try {
440
+ const args = process.argv.slice(2)
441
+ const reExecuted = await checkAndUpdateVersion(runPrompt, args)
442
+ if (reExecuted) {
443
+ return
444
+ }
445
+ } catch (_error) {
446
+ // Never block execution due to update check issues
447
+ }
448
+ }
449
+
450
+ // Handle node/vue package release
451
+ if (releaseType === 'node' || releaseType === 'vue') {
452
+ try {
453
+ await releaseNode()
454
+ return
455
+ } catch (error) {
456
+ logError('\nRelease failed:')
457
+ logError(error.message)
458
+ if (error.stack) {
459
+ writeStderrLine(error.stack)
460
+ }
461
+ process.exit(1)
462
+ }
463
+ }
464
+
465
+ // Handle packagist/composer package release
466
+ if (releaseType === 'packagist') {
467
+ try {
468
+ await releasePackagist()
469
+ return
470
+ } catch (error) {
471
+ logError('\nRelease failed:')
472
+ logError(error.message)
473
+ if (error.stack) {
474
+ writeStderrLine(error.stack)
475
+ }
476
+ process.exit(1)
477
+ }
478
+ }
479
+
480
+ // Default: Laravel deployment workflow
481
+ const rootDir = process.cwd()
482
+
483
+ await ensureGitignoreEntry(rootDir)
484
+ await ensureProjectReleaseScript(rootDir)
485
+
486
+ // Validate dependencies if package.json or composer.json exists
487
+ const packageJsonPath = path.join(rootDir, 'package.json')
488
+ const composerJsonPath = path.join(rootDir, 'composer.json')
489
+ const hasPackageJson = await fs.access(packageJsonPath).then(() => true).catch(() => false)
490
+ const hasComposerJson = await fs.access(composerJsonPath).then(() => true).catch(() => false)
491
+
492
+ if (hasPackageJson || hasComposerJson) {
493
+ logProcessing('Validating dependencies...')
494
+ await validateLocalDependencies(rootDir, runPrompt, logSuccess)
495
+ }
496
+
497
+ // Load servers first (they may be migrated)
498
+ const servers = await loadServers({ logSuccess, logWarning })
499
+ // Load project config with servers for migration
500
+ const projectConfig = await loadProjectConfig(rootDir, servers, { logSuccess, logWarning })
501
+
502
+ let server = null
503
+ let appConfig = null
504
+ let isCreatingNewPreset = false
505
+
506
+ const preset = await selectPreset(projectConfig, servers)
507
+
508
+ if (preset === 'create') {
509
+ // User explicitly chose to create a new preset
510
+ isCreatingNewPreset = true
511
+ server = await selectServer(servers)
512
+ appConfig = await selectApp(projectConfig, server, rootDir)
513
+ } else if (preset) {
514
+ // User selected an existing preset - look up by appId
515
+ if (preset.appId) {
516
+ appConfig = projectConfig.apps?.find((a) => a.id === preset.appId)
517
+
518
+ if (!appConfig) {
519
+ logWarning(`Preset references app configuration that no longer exists. Creating new configuration.`)
520
+ server = await selectServer(servers)
521
+ appConfig = await selectApp(projectConfig, server, rootDir)
522
+ } else {
523
+ server = servers.find((s) => s.id === appConfig.serverId || s.serverName === appConfig.serverName)
524
+
525
+ if (!server) {
526
+ logWarning(`Preset references server that no longer exists. Creating new configuration.`)
527
+ server = await selectServer(servers)
528
+ appConfig = await selectApp(projectConfig, server, rootDir)
529
+ } else if (preset.branch && appConfig.branch !== preset.branch) {
530
+ // Update branch if preset has a different branch
531
+ appConfig.branch = preset.branch
532
+ await saveProjectConfig(rootDir, projectConfig)
533
+ logSuccess(`Updated branch to ${preset.branch} from preset.`)
534
+ }
535
+ }
536
+ } else if (preset.key) {
537
+ // Legacy preset format - migrate it
538
+ const keyParts = preset.key.split(':')
539
+ const serverName = keyParts[0]
540
+ const projectPath = keyParts[1]
541
+ const presetBranch = preset.branch || (keyParts.length === 3 ? keyParts[2] : null)
542
+
543
+ server = servers.find((s) => s.serverName === serverName)
544
+
545
+ if (!server) {
546
+ logWarning(`Preset references server "${serverName}" which no longer exists. Creating new configuration.`)
547
+ server = await selectServer(servers)
548
+ appConfig = await selectApp(projectConfig, server, rootDir)
549
+ } else {
550
+ appConfig = projectConfig.apps?.find(
551
+ (a) => (a.serverId === server.id || a.serverName === serverName) && a.projectPath === projectPath
552
+ )
553
+
554
+ if (!appConfig) {
555
+ logWarning(`Preset references app configuration that no longer exists. Creating new configuration.`)
556
+ appConfig = await selectApp(projectConfig, server, rootDir)
557
+ } else {
558
+ // Migrate preset to use appId
559
+ preset.appId = appConfig.id
560
+ if (presetBranch && appConfig.branch !== presetBranch) {
561
+ appConfig.branch = presetBranch
562
+ }
563
+ preset.branch = appConfig.branch
564
+ await saveProjectConfig(rootDir, projectConfig)
565
+ }
566
+ }
567
+ } else {
568
+ logWarning(`Preset format is invalid. Creating new configuration.`)
569
+ server = await selectServer(servers)
570
+ appConfig = await selectApp(projectConfig, server, rootDir)
571
+ }
572
+ } else {
573
+ // No presets exist, go through normal flow
574
+ server = await selectServer(servers)
575
+ appConfig = await selectApp(projectConfig, server, rootDir)
576
+ }
577
+
578
+ const updated = await ensureSshDetails(appConfig, rootDir)
579
+
580
+ if (updated) {
581
+ await saveProjectConfig(rootDir, projectConfig)
582
+ logSuccess('Updated .zephyr/config.json with SSH details.')
583
+ }
584
+
585
+ const deploymentConfig = {
586
+ serverName: server.serverName,
587
+ serverIp: server.serverIp,
588
+ projectPath: appConfig.projectPath,
589
+ branch: appConfig.branch,
590
+ sshUser: appConfig.sshUser,
591
+ sshKey: appConfig.sshKey
592
+ }
593
+
594
+ logProcessing('\nSelected deployment target:')
595
+ writeStdoutLine(JSON.stringify(deploymentConfig, null, 2))
596
+
597
+ if (isCreatingNewPreset || !preset) {
598
+ const { presetName } = await runPrompt([
599
+ {
600
+ type: 'input',
601
+ name: 'presetName',
602
+ message: 'Enter a name for this preset (leave blank to skip)',
603
+ default: isCreatingNewPreset ? '' : undefined
604
+ }
605
+ ])
606
+
607
+ const trimmedName = presetName?.trim()
608
+
609
+ if (trimmedName && trimmedName.length > 0) {
610
+ const presets = projectConfig.presets ?? []
611
+
612
+ // Find app config to get its ID
613
+ const appId = appConfig.id
614
+
615
+ if (!appId) {
616
+ logWarning('Cannot save preset: app configuration missing ID.')
617
+ } else {
618
+ // Check if preset with this appId already exists
619
+ const existingIndex = presets.findIndex((p) => p.appId === appId)
620
+ if (existingIndex >= 0) {
621
+ presets[existingIndex].name = trimmedName
622
+ presets[existingIndex].branch = deploymentConfig.branch
623
+ } else {
624
+ presets.push({
625
+ name: trimmedName,
626
+ appId: appId,
627
+ branch: deploymentConfig.branch
628
+ })
629
+ }
630
+
631
+ projectConfig.presets = presets
632
+ await saveProjectConfig(rootDir, projectConfig)
633
+ logSuccess(`Saved preset "${trimmedName}" to .zephyr/config.json`)
634
+ }
635
+ }
636
+ }
637
+
638
+ const existingSnapshot = await loadPendingTasksSnapshot(rootDir)
639
+ let snapshotToUse = null
640
+
641
+ if (existingSnapshot) {
642
+ const matchesSelection =
643
+ existingSnapshot.serverName === deploymentConfig.serverName &&
644
+ existingSnapshot.branch === deploymentConfig.branch
645
+
646
+ const messageLines = [
647
+ 'Pending deployment tasks were detected from a previous run.',
648
+ `Server: ${existingSnapshot.serverName}`,
649
+ `Branch: ${existingSnapshot.branch}`
650
+ ]
651
+
652
+ if (existingSnapshot.taskLabels && existingSnapshot.taskLabels.length > 0) {
653
+ messageLines.push(`Tasks: ${existingSnapshot.taskLabels.join(', ')}`)
654
+ }
655
+
656
+ const { resumePendingTasks } = await runPrompt([
657
+ {
658
+ type: 'confirm',
659
+ name: 'resumePendingTasks',
660
+ message: `${messageLines.join(' | ')}. Resume using this plan?`,
661
+ default: matchesSelection
662
+ }
663
+ ])
664
+
665
+ if (resumePendingTasks) {
666
+ snapshotToUse = existingSnapshot
667
+ logProcessing('Resuming deployment using saved task snapshot...')
668
+ } else {
669
+ await clearPendingTasksSnapshot(rootDir)
670
+ logWarning('Discarded pending deployment snapshot.')
671
+ }
672
+ }
673
+
674
+ await runRemoteTasks(deploymentConfig, { rootDir, snapshot: snapshotToUse })
675
+ }
676
+
677
+ export { main, runRemoteTasks }