@wyxos/zephyr 0.2.27 → 0.3.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.
Files changed (35) hide show
  1. package/README.md +55 -2
  2. package/bin/zephyr.mjs +3 -1
  3. package/package.json +7 -2
  4. package/src/application/configuration/app-details.mjs +89 -0
  5. package/src/application/configuration/app-selection.mjs +87 -0
  6. package/src/application/configuration/preset-selection.mjs +59 -0
  7. package/src/application/configuration/select-deployment-target.mjs +165 -0
  8. package/src/application/configuration/server-selection.mjs +87 -0
  9. package/src/application/configuration/service.mjs +109 -0
  10. package/src/application/deploy/build-remote-deployment-plan.mjs +174 -0
  11. package/src/application/deploy/bump-local-package-version.mjs +81 -0
  12. package/src/application/deploy/execute-remote-deployment-plan.mjs +61 -0
  13. package/src/{utils/task-planner.mjs → application/deploy/plan-laravel-deployment-tasks.mjs} +5 -4
  14. package/src/application/deploy/prepare-local-deployment.mjs +52 -0
  15. package/src/application/deploy/resolve-local-deployment-context.mjs +17 -0
  16. package/src/application/deploy/resolve-pending-snapshot.mjs +45 -0
  17. package/src/application/deploy/run-deployment.mjs +147 -0
  18. package/src/application/deploy/run-local-deployment-checks.mjs +80 -0
  19. package/src/application/release/release-node-package.mjs +340 -0
  20. package/src/application/release/release-packagist-package.mjs +223 -0
  21. package/src/config/project.mjs +13 -0
  22. package/src/deploy/local-repo.mjs +187 -67
  23. package/src/deploy/preflight.mjs +10 -1
  24. package/src/deploy/remote-exec.mjs +2 -3
  25. package/src/index.mjs +27 -85
  26. package/src/main.mjs +80 -627
  27. package/src/release/shared.mjs +104 -0
  28. package/src/release-node.mjs +20 -481
  29. package/src/release-packagist.mjs +20 -291
  30. package/src/runtime/app-context.mjs +36 -0
  31. package/src/targets/index.mjs +24 -0
  32. package/src/utils/command.mjs +67 -5
  33. package/src/utils/output.mjs +41 -16
  34. package/src/utils/config-flow.mjs +0 -284
  35. /package/src/{utils/php-version.mjs → infrastructure/php/version.mjs} +0 -0
@@ -0,0 +1,109 @@
1
+ import {
2
+ ensureSshDetails as ensureSshDetailsBase,
3
+ promptSshDetails as promptSshDetailsBase
4
+ } from '../../ssh/keys.mjs'
5
+ import {saveProjectConfig} from '../../config/project.mjs'
6
+ import {saveServers} from '../../config/servers.mjs'
7
+ import {generateId} from '../../utils/id.mjs'
8
+ import {defaultProjectPath, listGitBranches, promptAppDetails} from './app-details.mjs'
9
+ import {selectApp} from './app-selection.mjs'
10
+ import {selectPreset} from './preset-selection.mjs'
11
+ import {promptServerDetails, selectServer} from './server-selection.mjs'
12
+
13
+ export {
14
+ defaultProjectPath,
15
+ listGitBranches,
16
+ promptAppDetails,
17
+ promptServerDetails,
18
+ selectServer,
19
+ selectApp,
20
+ selectPreset
21
+ }
22
+
23
+ function assertConfigurationDeps({
24
+ runPrompt,
25
+ runCommandCapture,
26
+ logProcessing,
27
+ logSuccess,
28
+ logWarning
29
+ } = {}) {
30
+ if (!runPrompt || !runCommandCapture || !logProcessing || !logSuccess || !logWarning) {
31
+ throw new Error('createConfigurationService requires prompt, command, and logger dependencies.')
32
+ }
33
+ }
34
+
35
+ export function createConfigurationService(deps = {}) {
36
+ assertConfigurationDeps(deps)
37
+
38
+ const {
39
+ runPrompt,
40
+ runCommandCapture,
41
+ logProcessing,
42
+ logSuccess,
43
+ logWarning
44
+ } = deps
45
+
46
+ const listBranches = (currentDir) => listGitBranches({
47
+ currentDir,
48
+ runCommandCapture,
49
+ logWarning
50
+ })
51
+
52
+ const promptSshDetails = (currentDir, existing = {}) => promptSshDetailsBase(currentDir, existing, {
53
+ runPrompt
54
+ })
55
+
56
+ const promptServerDetailsBound = (existingServers = []) => promptServerDetails({
57
+ existingServers,
58
+ runPrompt
59
+ })
60
+
61
+ const promptAppDetailsBound = (currentDir, existing = {}) => promptAppDetails({
62
+ currentDir,
63
+ existing,
64
+ runPrompt,
65
+ listGitBranches: listBranches,
66
+ resolveDefaultProjectPath: defaultProjectPath,
67
+ promptSshDetails
68
+ })
69
+
70
+ return {
71
+ ensureSshDetails(config, currentDir) {
72
+ return ensureSshDetailsBase(config, currentDir, {runPrompt, logProcessing})
73
+ },
74
+
75
+ selectServer(servers) {
76
+ return selectServer({
77
+ servers,
78
+ runPrompt,
79
+ logProcessing,
80
+ logSuccess,
81
+ persistServers: saveServers,
82
+ promptServerDetails: promptServerDetailsBound
83
+ })
84
+ },
85
+
86
+ selectApp(projectConfig, server, currentDir) {
87
+ return selectApp({
88
+ projectConfig,
89
+ server,
90
+ currentDir,
91
+ runPrompt,
92
+ logWarning,
93
+ logProcessing,
94
+ logSuccess,
95
+ persistProjectConfig: saveProjectConfig,
96
+ createId: generateId,
97
+ promptAppDetails: promptAppDetailsBound
98
+ })
99
+ },
100
+
101
+ selectPreset(projectConfig, servers) {
102
+ return selectPreset({
103
+ projectConfig,
104
+ servers,
105
+ runPrompt
106
+ })
107
+ }
108
+ }
109
+ }
@@ -0,0 +1,174 @@
1
+ import {findPhpBinary} from '../../infrastructure/php/version.mjs'
2
+ import {planLaravelDeploymentTasks} from './plan-laravel-deployment-tasks.mjs'
3
+
4
+ async function detectRemoteLaravelProject(ssh, remoteCwd) {
5
+ const laravelCheck = await ssh.execCommand(
6
+ 'if [ -f artisan ] && [ -f composer.json ] && grep -q "laravel/framework" composer.json; then echo "yes"; else echo "no"; fi',
7
+ {cwd: remoteCwd}
8
+ )
9
+
10
+ return laravelCheck.stdout.trim() === 'yes'
11
+ }
12
+
13
+ async function collectChangedFiles({
14
+ config,
15
+ snapshot,
16
+ remoteIsLaravel,
17
+ executeRemote,
18
+ logProcessing
19
+ } = {}) {
20
+ if (snapshot?.changedFiles) {
21
+ logProcessing?.('Resuming deployment with saved task snapshot.')
22
+ return snapshot.changedFiles
23
+ }
24
+
25
+ if (!remoteIsLaravel) {
26
+ return []
27
+ }
28
+
29
+ await executeRemote(`Fetch latest changes for ${config.branch}`, `git fetch origin ${config.branch}`)
30
+
31
+ const diffResult = await executeRemote(
32
+ 'Inspect pending changes',
33
+ `git diff --name-only HEAD..origin/${config.branch}`,
34
+ {printStdout: false}
35
+ )
36
+
37
+ const changedFiles = diffResult.stdout
38
+ .split(/\r?\n/)
39
+ .map((line) => line.trim())
40
+ .filter(Boolean)
41
+
42
+ if (changedFiles.length > 0) {
43
+ const preview = changedFiles
44
+ .slice(0, 20)
45
+ .map((file) => ` - ${file}`)
46
+ .join('\n')
47
+
48
+ logProcessing?.(
49
+ `Detected ${changedFiles.length} changed file(s):\n${preview}${changedFiles.length > 20 ? '\n - ...' : ''}`
50
+ )
51
+ } else {
52
+ logProcessing?.('No upstream file changes detected.')
53
+ }
54
+
55
+ return changedFiles
56
+ }
57
+
58
+ async function detectHorizonConfiguration({
59
+ ssh,
60
+ remoteCwd,
61
+ remoteIsLaravel,
62
+ changedFiles
63
+ } = {}) {
64
+ const hasPhpChanges = remoteIsLaravel && changedFiles.some((file) => file.endsWith('.php'))
65
+
66
+ if (!hasPhpChanges) {
67
+ return false
68
+ }
69
+
70
+ const horizonCheck = await ssh.execCommand(
71
+ 'if [ -f config/horizon.php ]; then echo "yes"; else echo "no"; fi',
72
+ {cwd: remoteCwd}
73
+ )
74
+
75
+ return horizonCheck.stdout.trim() === 'yes'
76
+ }
77
+
78
+ async function resolvePhpCommand({
79
+ requiredPhpVersion,
80
+ ssh,
81
+ remoteCwd,
82
+ logProcessing,
83
+ logWarning
84
+ } = {}) {
85
+ if (!requiredPhpVersion) {
86
+ return 'php'
87
+ }
88
+
89
+ try {
90
+ const phpCommand = await findPhpBinary(ssh, remoteCwd, requiredPhpVersion)
91
+ if (phpCommand !== 'php') {
92
+ logProcessing?.(`Detected PHP requirement: ${requiredPhpVersion}, using ${phpCommand}`)
93
+ }
94
+
95
+ return phpCommand
96
+ } catch (error) {
97
+ logWarning?.(`Could not find PHP binary for version ${requiredPhpVersion}: ${error.message}`)
98
+ return 'php'
99
+ }
100
+ }
101
+
102
+ export async function buildRemoteDeploymentPlan({
103
+ config,
104
+ snapshot = null,
105
+ requiredPhpVersion = null,
106
+ ssh,
107
+ remoteCwd,
108
+ executeRemote,
109
+ logProcessing,
110
+ logSuccess,
111
+ logWarning
112
+ } = {}) {
113
+ const remoteIsLaravel = await detectRemoteLaravelProject(ssh, remoteCwd)
114
+
115
+ if (remoteIsLaravel) {
116
+ logSuccess?.('Laravel project detected.')
117
+ } else {
118
+ logWarning?.('Laravel project not detected; skipping Laravel-specific maintenance tasks.')
119
+ }
120
+
121
+ const changedFiles = await collectChangedFiles({
122
+ config,
123
+ snapshot,
124
+ remoteIsLaravel,
125
+ executeRemote,
126
+ logProcessing
127
+ })
128
+
129
+ const horizonConfigured = await detectHorizonConfiguration({
130
+ ssh,
131
+ remoteCwd,
132
+ remoteIsLaravel,
133
+ changedFiles
134
+ })
135
+
136
+ const phpCommand = await resolvePhpCommand({
137
+ requiredPhpVersion,
138
+ ssh,
139
+ remoteCwd,
140
+ logProcessing,
141
+ logWarning
142
+ })
143
+
144
+ const steps = planLaravelDeploymentTasks({
145
+ branch: config.branch,
146
+ isLaravel: remoteIsLaravel,
147
+ changedFiles,
148
+ horizonConfigured,
149
+ phpCommand
150
+ })
151
+
152
+ const usefulSteps = steps.length > 1
153
+ const pendingSnapshot = !usefulSteps
154
+ ? null
155
+ : snapshot ?? {
156
+ serverName: config.serverName,
157
+ branch: config.branch,
158
+ projectPath: config.projectPath,
159
+ sshUser: config.sshUser,
160
+ createdAt: new Date().toISOString(),
161
+ changedFiles,
162
+ taskLabels: steps.map((step) => step.label)
163
+ }
164
+
165
+ return {
166
+ remoteIsLaravel,
167
+ changedFiles,
168
+ horizonConfigured,
169
+ phpCommand,
170
+ steps,
171
+ usefulSteps,
172
+ pendingSnapshot
173
+ }
174
+ }
@@ -0,0 +1,81 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import {commandExists} from '../../utils/command.mjs'
5
+
6
+ async function readPackageJson(rootDir) {
7
+ const packageJsonPath = path.join(rootDir, 'package.json')
8
+ const raw = await fs.readFile(packageJsonPath, 'utf8')
9
+ return JSON.parse(raw)
10
+ }
11
+
12
+ async function isGitIgnored(rootDir, filePath, {runCommand} = {}) {
13
+ try {
14
+ await runCommand('git', ['check-ignore', '-q', filePath], {cwd: rootDir})
15
+ return true
16
+ } catch {
17
+ return false
18
+ }
19
+ }
20
+
21
+ export async function bumpLocalPackageVersion(rootDir, {
22
+ versionArg = null,
23
+ runCommand,
24
+ logProcessing,
25
+ logSuccess,
26
+ logWarning
27
+ } = {}) {
28
+ if (!commandExists('npm')) {
29
+ logWarning?.('npm is not available in PATH. Skipping npm version bump.')
30
+ return null
31
+ }
32
+
33
+ let pkg
34
+ try {
35
+ pkg = await readPackageJson(rootDir)
36
+ } catch {
37
+ return null
38
+ }
39
+
40
+ if (!pkg?.version) {
41
+ return null
42
+ }
43
+
44
+ const releaseValue = (versionArg && String(versionArg).trim().length > 0)
45
+ ? String(versionArg).trim()
46
+ : 'patch'
47
+
48
+ logProcessing?.(`Bumping npm package version (${releaseValue})...`)
49
+ await runCommand('npm', ['version', releaseValue, '--no-git-tag-version', '--force'], {cwd: rootDir})
50
+
51
+ const updatedPkg = await readPackageJson(rootDir)
52
+ const nextVersion = updatedPkg?.version ?? pkg.version
53
+ const didVersionChange = nextVersion !== pkg.version
54
+
55
+ const filesToStage = ['package.json']
56
+ const packageLockPath = path.join(rootDir, 'package-lock.json')
57
+
58
+ try {
59
+ await fs.access(packageLockPath)
60
+ const ignored = await isGitIgnored(rootDir, 'package-lock.json', {runCommand})
61
+ if (!ignored) {
62
+ filesToStage.push('package-lock.json')
63
+ }
64
+ } catch {
65
+ // package-lock.json does not exist
66
+ }
67
+
68
+ await runCommand('git', ['add', ...filesToStage], {cwd: rootDir})
69
+
70
+ if (!didVersionChange) {
71
+ logWarning?.('Version did not change after npm version. Skipping version commit.')
72
+ return updatedPkg
73
+ }
74
+
75
+ await runCommand('git', ['commit', '-m', `chore: bump version to ${nextVersion}`, '--', ...filesToStage], {
76
+ cwd: rootDir
77
+ })
78
+ logSuccess?.(`Version updated to ${nextVersion}.`)
79
+
80
+ return updatedPkg
81
+ }
@@ -0,0 +1,61 @@
1
+ import {clearPendingTasksSnapshot, savePendingTasksSnapshot} from '../../deploy/snapshots.mjs'
2
+ import {PENDING_TASKS_FILE} from '../../utils/paths.mjs'
3
+
4
+ async function persistPendingSnapshot(rootDir, pendingSnapshot, executeRemote) {
5
+ await savePendingTasksSnapshot(rootDir, pendingSnapshot)
6
+
7
+ const payload = Buffer.from(JSON.stringify(pendingSnapshot)).toString('base64')
8
+ await executeRemote(
9
+ 'Record pending deployment tasks',
10
+ `mkdir -p .zephyr && echo '${payload}' | base64 --decode > .zephyr/${PENDING_TASKS_FILE}`,
11
+ {printStdout: false}
12
+ )
13
+ }
14
+
15
+ function logScheduledTasks(steps, {logProcessing} = {}) {
16
+ if (steps.length === 1) {
17
+ logProcessing?.('No additional maintenance tasks scheduled beyond git pull.')
18
+ return
19
+ }
20
+
21
+ const extraTasks = steps
22
+ .slice(1)
23
+ .map((step) => step.label)
24
+ .join(', ')
25
+
26
+ logProcessing?.(`Additional tasks scheduled: ${extraTasks}`)
27
+ }
28
+
29
+ export async function executeRemoteDeploymentPlan({
30
+ rootDir,
31
+ executeRemote,
32
+ steps,
33
+ usefulSteps,
34
+ pendingSnapshot = null,
35
+ logProcessing
36
+ } = {}) {
37
+ if (usefulSteps && pendingSnapshot) {
38
+ await persistPendingSnapshot(rootDir, pendingSnapshot, executeRemote)
39
+ }
40
+
41
+ logScheduledTasks(steps, {logProcessing})
42
+
43
+ let completed = false
44
+
45
+ try {
46
+ for (const step of steps) {
47
+ await executeRemote(step.label, step.command)
48
+ }
49
+
50
+ completed = true
51
+ } finally {
52
+ if (usefulSteps && completed) {
53
+ await executeRemote(
54
+ 'Clear pending deployment snapshot',
55
+ `rm -f .zephyr/${PENDING_TASKS_FILE}`,
56
+ {printStdout: false, allowFailure: true}
57
+ )
58
+ await clearPendingTasksSnapshot(rootDir)
59
+ }
60
+ }
61
+ }
@@ -52,10 +52,12 @@ export function planLaravelDeploymentTasks({
52
52
 
53
53
  if (shouldRunComposer) {
54
54
  // Composer is a PHP script, so we need to run it with the correct PHP version
55
- // Try composer.phar first, then system composer, ensuring it uses the correct PHP
55
+ // Deployments should be lockfile-based and reproducible.
56
+ // `composer update --no-dev` still resolves require-dev and can fail on production PHP versions.
57
+ // Prefer `composer install --no-dev` and fail loudly if composer.lock is missing.
56
58
  steps.push({
57
- label: 'Update Composer dependencies',
58
- command: `if [ -f composer.phar ]; then ${phpCommand} composer.phar update --no-dev --no-interaction --prefer-dist; elif command -v composer >/dev/null 2>&1; then ${phpCommand} $(command -v composer) update --no-dev --no-interaction --prefer-dist; else ${phpCommand} composer update --no-dev --no-interaction --prefer-dist; fi`
59
+ label: 'Install Composer dependencies',
60
+ command: `if [ ! -f composer.lock ]; then echo "composer.lock is missing; commit composer.lock for reproducible deploys." >&2; exit 1; fi; if [ -f composer.phar ]; then ${phpCommand} composer.phar install --no-dev --no-interaction --prefer-dist --optimize-autoloader; elif command -v composer >/dev/null 2>&1; then ${phpCommand} $(command -v composer) install --no-dev --no-interaction --prefer-dist --optimize-autoloader; else ${phpCommand} composer install --no-dev --no-interaction --prefer-dist --optimize-autoloader; fi`
59
61
  })
60
62
  }
61
63
 
@@ -96,4 +98,3 @@ export function planLaravelDeploymentTasks({
96
98
 
97
99
  return steps
98
100
  }
99
-
@@ -0,0 +1,52 @@
1
+ import process from 'node:process'
2
+
3
+ import {ensureLocalRepositoryState} from '../../deploy/local-repo.mjs'
4
+ import {bumpLocalPackageVersion} from './bump-local-package-version.mjs'
5
+ import {resolveLocalDeploymentContext} from './resolve-local-deployment-context.mjs'
6
+ import {runLocalDeploymentChecks} from './run-local-deployment-checks.mjs'
7
+
8
+ export async function prepareLocalDeployment(config, {
9
+ snapshot = null,
10
+ rootDir = process.cwd(),
11
+ versionArg = null,
12
+ runPrompt,
13
+ runCommand,
14
+ runCommandCapture,
15
+ logProcessing,
16
+ logSuccess,
17
+ logWarning
18
+ } = {}) {
19
+ const context = await resolveLocalDeploymentContext(rootDir)
20
+
21
+ if (!snapshot && context.isLaravel) {
22
+ await bumpLocalPackageVersion(rootDir, {
23
+ versionArg,
24
+ runCommand,
25
+ logProcessing,
26
+ logSuccess,
27
+ logWarning
28
+ })
29
+ }
30
+
31
+ await ensureLocalRepositoryState(config.branch, rootDir, {
32
+ runPrompt,
33
+ runCommand,
34
+ runCommandCapture,
35
+ logProcessing,
36
+ logSuccess,
37
+ logWarning
38
+ })
39
+
40
+ await runLocalDeploymentChecks({
41
+ rootDir,
42
+ isLaravel: context.isLaravel,
43
+ hasHook: context.hasHook,
44
+ runCommand,
45
+ runCommandCapture,
46
+ logProcessing,
47
+ logSuccess,
48
+ logWarning
49
+ })
50
+
51
+ return context
52
+ }
@@ -0,0 +1,17 @@
1
+ import * as preflight from '../../deploy/preflight.mjs'
2
+ import {getPhpVersionRequirement} from '../../infrastructure/php/version.mjs'
3
+
4
+ export async function resolveLocalDeploymentContext(rootDir) {
5
+ let requiredPhpVersion = null
6
+
7
+ try {
8
+ requiredPhpVersion = await getPhpVersionRequirement(rootDir)
9
+ } catch {
10
+ // composer.json might not exist or be unreadable
11
+ }
12
+
13
+ const isLaravel = await preflight.isLocalLaravelProject(rootDir)
14
+ const hasHook = await preflight.hasPrePushHook(rootDir)
15
+
16
+ return {requiredPhpVersion, isLaravel, hasHook}
17
+ }
@@ -0,0 +1,45 @@
1
+ import {clearPendingTasksSnapshot, loadPendingTasksSnapshot} from '../../deploy/snapshots.mjs'
2
+
3
+ export async function resolvePendingSnapshot(rootDir, deploymentConfig, {
4
+ runPrompt,
5
+ logProcessing,
6
+ logWarning
7
+ } = {}) {
8
+ const existingSnapshot = await loadPendingTasksSnapshot(rootDir)
9
+
10
+ if (!existingSnapshot) {
11
+ return null
12
+ }
13
+
14
+ const matchesSelection =
15
+ existingSnapshot.serverName === deploymentConfig.serverName &&
16
+ existingSnapshot.branch === deploymentConfig.branch
17
+
18
+ const messageLines = [
19
+ 'Pending deployment tasks were detected from a previous run.',
20
+ `Server: ${existingSnapshot.serverName}`,
21
+ `Branch: ${existingSnapshot.branch}`
22
+ ]
23
+
24
+ if (existingSnapshot.taskLabels && existingSnapshot.taskLabels.length > 0) {
25
+ messageLines.push(`Tasks: ${existingSnapshot.taskLabels.join(', ')}`)
26
+ }
27
+
28
+ const {resumePendingTasks} = await runPrompt([
29
+ {
30
+ type: 'confirm',
31
+ name: 'resumePendingTasks',
32
+ message: `${messageLines.join(' | ')}. Resume using this plan?`,
33
+ default: matchesSelection
34
+ }
35
+ ])
36
+
37
+ if (resumePendingTasks) {
38
+ logProcessing?.('Resuming deployment using saved task snapshot...')
39
+ return existingSnapshot
40
+ }
41
+
42
+ await clearPendingTasksSnapshot(rootDir)
43
+ logWarning?.('Discarded pending deployment snapshot.')
44
+ return null
45
+ }