@wyxos/zephyr 0.2.18 → 0.2.19

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 CHANGED
@@ -22,6 +22,22 @@ Navigate to your project directory and run:
22
22
  zephyr
23
23
  ```
24
24
 
25
+ See all flags:
26
+
27
+ ```bash
28
+ zephyr --help
29
+ ```
30
+
31
+ Common flags:
32
+
33
+ ```bash
34
+ # Run a release workflow
35
+ zephyr --type node
36
+
37
+ # Skip the best-effort update check for this run
38
+ zephyr --skip-version-check
39
+ ```
40
+
25
41
  Follow the interactive prompts to configure your deployment target:
26
42
  - Server name and IP address
27
43
  - Project path on the remote server
@@ -34,7 +50,9 @@ Configuration is saved automatically for future deployments.
34
50
 
35
51
  When run via `npx`, Zephyr can prompt to re-run itself using the latest published version.
36
52
 
37
- - **Skip update check**: set `ZEPHYR_SKIP_VERSION_CHECK=1`
53
+ - **Skip update check**:
54
+ - Set `ZEPHYR_SKIP_VERSION_CHECK=1`, or
55
+ - Use `zephyr --skip-version-check`
38
56
 
39
57
  ## Features
40
58
 
package/bin/zephyr.mjs CHANGED
@@ -1,14 +1,29 @@
1
1
  #!/usr/bin/env node
2
2
  import process from 'node:process'
3
- import { logError, main } from '../src/index.mjs'
3
+ import { Command } from 'commander'
4
+ import chalk from 'chalk'
5
+ import { main } from '../src/main.mjs'
6
+ import { createChalkLogger } from '../src/utils/output.mjs'
4
7
 
5
- // Parse --type flag from command line arguments
6
- const args = process.argv.slice(2)
7
- const typeFlag = args.find(arg => arg.startsWith('--type='))
8
- const releaseType = typeFlag ? typeFlag.split('=')[1] : null
8
+ const { logError } = createChalkLogger(chalk)
9
+
10
+ const program = new Command()
11
+
12
+ program
13
+ .name('zephyr')
14
+ .description('A streamlined deployment tool for web applications with intelligent Laravel project detection')
15
+ .option('--type <type>', 'Release type (node|vue|packagist)')
16
+ .option('--skip-version-check', 'Skip the version check for this run')
17
+
18
+ program.parse(process.argv)
19
+ const options = program.opts()
20
+
21
+ if (options.skipVersionCheck) {
22
+ process.env.ZEPHYR_SKIP_VERSION_CHECK = '1'
23
+ }
9
24
 
10
25
  try {
11
- await main(releaseType)
26
+ await main(options.type ?? null)
12
27
  } catch (error) {
13
28
  logError(error?.message || String(error))
14
29
  process.exitCode = 1
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.2.18",
3
+ "version": "0.2.19",
4
4
  "description": "A streamlined deployment tool for web applications with intelligent Laravel project detection",
5
5
  "type": "module",
6
- "main": "./src/index.mjs",
6
+ "main": "./src/main.mjs",
7
7
  "exports": {
8
- ".": "./src/index.mjs",
9
- "./ssh-utils": "./src/ssh-utils.mjs"
8
+ ".": "./src/main.mjs",
9
+ "./ssh": "./src/ssh/index.mjs"
10
10
  },
11
11
  "bin": {
12
12
  "zephyr": "bin/zephyr.mjs"
@@ -44,6 +44,7 @@
44
44
  ],
45
45
  "dependencies": {
46
46
  "chalk": "5.3.0",
47
+ "commander": "11.1.0",
47
48
  "inquirer": "^9.2.12",
48
49
  "node-ssh": "^13.1.0",
49
50
  "semver": "^7.6.3"
@@ -0,0 +1,118 @@
1
+ import fs from 'node:fs/promises'
2
+
3
+ import { ensureDirectory, getProjectConfigDir, getProjectConfigPath } from '../utils/paths.mjs'
4
+ import { generateId } from '../utils/id.mjs'
5
+
6
+ export function migrateApps(apps, servers) {
7
+ if (!Array.isArray(apps)) {
8
+ return { apps: [], needsMigration: false }
9
+ }
10
+
11
+ const serverNameToId = new Map()
12
+ servers.forEach((server) => {
13
+ if (server.id && server.serverName) {
14
+ serverNameToId.set(server.serverName, server.id)
15
+ }
16
+ })
17
+
18
+ let needsMigration = false
19
+ const migrated = apps.map((app) => {
20
+ const updated = { ...app }
21
+
22
+ if (!app.id) {
23
+ needsMigration = true
24
+ updated.id = generateId()
25
+ }
26
+
27
+ if (app.serverName && !app.serverId) {
28
+ const serverId = serverNameToId.get(app.serverName)
29
+ if (serverId) {
30
+ needsMigration = true
31
+ updated.serverId = serverId
32
+ }
33
+ }
34
+
35
+ return updated
36
+ })
37
+
38
+ return { apps: migrated, needsMigration }
39
+ }
40
+
41
+ export function migratePresets(presets, apps) {
42
+ if (!Array.isArray(presets)) {
43
+ return { presets: [], needsMigration: false }
44
+ }
45
+
46
+ const keyToAppId = new Map()
47
+ apps.forEach((app) => {
48
+ if (app.id && app.serverName && app.projectPath) {
49
+ const key = `${app.serverName}:${app.projectPath}`
50
+ keyToAppId.set(key, app.id)
51
+ }
52
+ })
53
+
54
+ let needsMigration = false
55
+ const migrated = presets.map((preset) => {
56
+ const updated = { ...preset }
57
+
58
+ if (preset.key && !preset.appId) {
59
+ const appId = keyToAppId.get(preset.key)
60
+ if (appId) {
61
+ needsMigration = true
62
+ updated.appId = appId
63
+ }
64
+ }
65
+
66
+ return updated
67
+ })
68
+
69
+ return { presets: migrated, needsMigration }
70
+ }
71
+
72
+ export async function loadProjectConfig(rootDir, servers = [], { logSuccess, logWarning } = {}) {
73
+ const configPath = getProjectConfigPath(rootDir)
74
+
75
+ try {
76
+ const raw = await fs.readFile(configPath, 'utf8')
77
+ const data = JSON.parse(raw)
78
+ const apps = Array.isArray(data?.apps) ? data.apps : []
79
+ const presets = Array.isArray(data?.presets) ? data.presets : []
80
+
81
+ const { apps: migratedApps, needsMigration: appsNeedMigration } = migrateApps(apps, servers)
82
+ const { presets: migratedPresets, needsMigration: presetsNeedMigration } = migratePresets(presets, migratedApps)
83
+
84
+ if (appsNeedMigration || presetsNeedMigration) {
85
+ await saveProjectConfig(rootDir, {
86
+ apps: migratedApps,
87
+ presets: migratedPresets
88
+ })
89
+ logSuccess?.('Migrated project configuration to use unique IDs.')
90
+ }
91
+
92
+ return { apps: migratedApps, presets: migratedPresets }
93
+ } catch (error) {
94
+ if (error.code === 'ENOENT') {
95
+ return { apps: [], presets: [] }
96
+ }
97
+
98
+ logWarning?.('Failed to read .zephyr/config.json, starting with an empty list of apps.')
99
+ return { apps: [], presets: [] }
100
+ }
101
+ }
102
+
103
+ export async function saveProjectConfig(rootDir, config) {
104
+ const configDir = getProjectConfigDir(rootDir)
105
+ await ensureDirectory(configDir)
106
+
107
+ const payload = JSON.stringify(
108
+ {
109
+ apps: config.apps ?? [],
110
+ presets: config.presets ?? []
111
+ },
112
+ null,
113
+ 2
114
+ )
115
+
116
+ await fs.writeFile(getProjectConfigPath(rootDir), `${payload}\n`)
117
+ }
118
+
@@ -0,0 +1,57 @@
1
+ import fs from 'node:fs/promises'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+
5
+ import { ensureDirectory } from '../utils/paths.mjs'
6
+ import { generateId } from '../utils/id.mjs'
7
+
8
+ const GLOBAL_CONFIG_DIR = path.join(os.homedir(), '.config', 'zephyr')
9
+ const SERVERS_FILE = path.join(GLOBAL_CONFIG_DIR, 'servers.json')
10
+
11
+ export function migrateServers(servers) {
12
+ if (!Array.isArray(servers)) {
13
+ return { servers: [], needsMigration: false }
14
+ }
15
+
16
+ let needsMigration = false
17
+ const migrated = servers.map((server) => {
18
+ if (!server.id) {
19
+ needsMigration = true
20
+ return { ...server, id: generateId() }
21
+ }
22
+ return server
23
+ })
24
+
25
+ return { servers: migrated, needsMigration }
26
+ }
27
+
28
+ export async function loadServers({ logSuccess, logWarning } = {}) {
29
+ try {
30
+ const raw = await fs.readFile(SERVERS_FILE, 'utf8')
31
+ const data = JSON.parse(raw)
32
+ const servers = Array.isArray(data) ? data : []
33
+
34
+ const { servers: migrated, needsMigration } = migrateServers(servers)
35
+
36
+ if (needsMigration) {
37
+ await saveServers(migrated)
38
+ logSuccess?.('Migrated servers configuration to use unique IDs.')
39
+ }
40
+
41
+ return migrated
42
+ } catch (error) {
43
+ if (error.code === 'ENOENT') {
44
+ return []
45
+ }
46
+
47
+ logWarning?.('Failed to read servers.json, starting with an empty list.')
48
+ return []
49
+ }
50
+ }
51
+
52
+ export async function saveServers(servers) {
53
+ await ensureDirectory(GLOBAL_CONFIG_DIR)
54
+ const payload = JSON.stringify(servers, null, 2)
55
+ await fs.writeFile(SERVERS_FILE, `${payload}\n`)
56
+ }
57
+
@@ -1,10 +1,8 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises'
2
2
  import path from 'node:path'
3
- import { spawn } from 'node:child_process'
4
3
  import process from 'node:process'
5
4
  import chalk from 'chalk'
6
-
7
- const IS_WINDOWS = process.platform === 'win32'
5
+ import { runCommand as runCommandBase, runCommandCapture as runCommandCaptureBase } from './utils/command.mjs'
8
6
 
9
7
  function isLocalPathOutsideRepo(depPath, rootDir) {
10
8
  if (!depPath || typeof depPath !== 'string') {
@@ -218,45 +216,13 @@ async function updateComposerJsonDependency(rootDir, packageName, newVersion, fi
218
216
  }
219
217
 
220
218
  async function runCommand(command, args, { cwd = process.cwd(), capture = false } = {}) {
221
- return new Promise((resolve, reject) => {
222
- const resolvedCommand = IS_WINDOWS && (command === 'npm' || command === 'npx' || command === 'pnpm' || command === 'yarn')
223
- ? `${command}.cmd`
224
- : command
225
-
226
- const spawnOptions = {
227
- stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
228
- cwd
229
- }
230
-
231
- const child = spawn(resolvedCommand, args, spawnOptions)
232
- let stdout = ''
233
- let stderr = ''
234
-
235
- if (capture) {
236
- child.stdout.on('data', (chunk) => {
237
- stdout += chunk
238
- })
239
-
240
- child.stderr.on('data', (chunk) => {
241
- stderr += chunk
242
- })
243
- }
219
+ if (capture) {
220
+ const { stdout, stderr } = await runCommandCaptureBase(command, args, { cwd })
221
+ return { stdout: stdout.trim(), stderr: stderr.trim() }
222
+ }
244
223
 
245
- child.on('error', reject)
246
- child.on('close', (code) => {
247
- if (code === 0) {
248
- resolve(capture ? { stdout: stdout.trim(), stderr: stderr.trim() } : undefined)
249
- } else {
250
- const error = new Error(`Command failed (${code}): ${resolvedCommand} ${args.join(' ')}`)
251
- if (capture) {
252
- error.stdout = stdout
253
- error.stderr = stderr
254
- }
255
- error.exitCode = code
256
- reject(error)
257
- }
258
- })
259
- })
224
+ await runCommandBase(command, args, { cwd })
225
+ return undefined
260
226
  }
261
227
 
262
228
  async function getGitStatus(rootDir) {
@@ -0,0 +1,215 @@
1
+ import { getCurrentBranch as getCurrentBranchImpl, getUpstreamRef as getUpstreamRefImpl } from '../utils/git.mjs'
2
+
3
+ export async function getCurrentBranch(rootDir) {
4
+ const branch = await getCurrentBranchImpl(rootDir)
5
+ return branch ?? ''
6
+ }
7
+
8
+ export async function getGitStatus(rootDir, { runCommandCapture } = {}) {
9
+ const output = await runCommandCapture('git', ['status', '--porcelain'], { cwd: rootDir })
10
+ return output.trim()
11
+ }
12
+
13
+ export function hasStagedChanges(statusOutput) {
14
+ if (!statusOutput || statusOutput.length === 0) {
15
+ return false
16
+ }
17
+
18
+ const lines = statusOutput.split('\n').filter((line) => line.trim().length > 0)
19
+
20
+ return lines.some((line) => {
21
+ const firstChar = line[0]
22
+ return firstChar && firstChar !== ' ' && firstChar !== '?'
23
+ })
24
+ }
25
+
26
+ export async function hasUncommittedChanges(rootDir, { getGitStatus: getGitStatusFn } = {}) {
27
+ const status = await getGitStatusFn(rootDir)
28
+ return status.length > 0
29
+ }
30
+
31
+ export async function getUpstreamRef(rootDir) {
32
+ return await getUpstreamRefImpl(rootDir)
33
+ }
34
+
35
+ export async function ensureCommittedChangesPushed(targetBranch, rootDir, {
36
+ runCommand,
37
+ runCommandCapture,
38
+ logProcessing,
39
+ logSuccess,
40
+ logWarning,
41
+ getUpstreamRef: getUpstreamRefFn = getUpstreamRef
42
+ } = {}) {
43
+ const upstreamRef = await getUpstreamRefFn(rootDir)
44
+
45
+ if (!upstreamRef) {
46
+ logWarning?.(`Branch ${targetBranch} does not track a remote upstream; skipping automatic push of committed changes.`)
47
+ return { pushed: false, upstreamRef: null }
48
+ }
49
+
50
+ const [remoteName, ...upstreamParts] = upstreamRef.split('/')
51
+ const upstreamBranch = upstreamParts.join('/')
52
+
53
+ if (!remoteName || !upstreamBranch) {
54
+ logWarning?.(`Unable to determine remote destination for ${targetBranch}. Skipping automatic push.`)
55
+ return { pushed: false, upstreamRef }
56
+ }
57
+
58
+ try {
59
+ await runCommand('git', ['fetch', remoteName], { cwd: rootDir, silent: true })
60
+ } catch (error) {
61
+ logWarning?.(`Unable to fetch from ${remoteName} before push: ${error.message}`)
62
+ }
63
+
64
+ let remoteExists = true
65
+
66
+ try {
67
+ await runCommand('git', ['show-ref', '--verify', '--quiet', `refs/remotes/${upstreamRef}`], {
68
+ cwd: rootDir,
69
+ silent: true
70
+ })
71
+ } catch {
72
+ remoteExists = false
73
+ }
74
+
75
+ let aheadCount = 0
76
+ let behindCount = 0
77
+
78
+ if (remoteExists) {
79
+ const aheadOutput = await runCommandCapture('git', ['rev-list', '--count', `${upstreamRef}..HEAD`], { cwd: rootDir })
80
+ aheadCount = parseInt(aheadOutput.trim() || '0', 10)
81
+
82
+ const behindOutput = await runCommandCapture('git', ['rev-list', '--count', `HEAD..${upstreamRef}`], { cwd: rootDir })
83
+ behindCount = parseInt(behindOutput.trim() || '0', 10)
84
+ } else {
85
+ aheadCount = 1
86
+ }
87
+
88
+ if (Number.isFinite(behindCount) && behindCount > 0) {
89
+ throw new Error(
90
+ `Local branch ${targetBranch} is behind ${upstreamRef} by ${behindCount} commit${behindCount === 1 ? '' : 's'}. Pull or rebase before deployment.`
91
+ )
92
+ }
93
+
94
+ if (!Number.isFinite(aheadCount) || aheadCount <= 0) {
95
+ return { pushed: false, upstreamRef }
96
+ }
97
+
98
+ const commitLabel = aheadCount === 1 ? 'commit' : 'commits'
99
+ logProcessing?.(`Found ${aheadCount} ${commitLabel} not yet pushed to ${upstreamRef}. Pushing before deployment...`)
100
+
101
+ await runCommandCapture('git', ['push', remoteName, `${targetBranch}:${upstreamBranch}`], { cwd: rootDir })
102
+ logSuccess?.(`Pushed committed changes to ${upstreamRef}.`)
103
+
104
+ return { pushed: true, upstreamRef }
105
+ }
106
+
107
+ export async function ensureLocalRepositoryState(targetBranch, rootDir = process.cwd(), {
108
+ runPrompt,
109
+ runCommand,
110
+ runCommandCapture,
111
+ logProcessing,
112
+ logSuccess,
113
+ logWarning,
114
+ getCurrentBranch: getCurrentBranchFn = getCurrentBranch,
115
+ getGitStatus: getGitStatusFn = (dir) => getGitStatus(dir, { runCommandCapture }),
116
+ ensureCommittedChangesPushed: ensureCommittedChangesPushedFn = (branch, dir) =>
117
+ ensureCommittedChangesPushed(branch, dir, {
118
+ runCommand,
119
+ runCommandCapture,
120
+ logProcessing,
121
+ logSuccess,
122
+ logWarning
123
+ })
124
+ } = {}) {
125
+ if (!targetBranch) {
126
+ throw new Error('Deployment branch is not defined in the release configuration.')
127
+ }
128
+
129
+ const currentBranch = await getCurrentBranchFn(rootDir)
130
+
131
+ if (!currentBranch) {
132
+ throw new Error('Unable to determine the current git branch. Ensure this is a git repository.')
133
+ }
134
+
135
+ const initialStatus = await getGitStatusFn(rootDir)
136
+ const hasPendingChanges = initialStatus.length > 0
137
+
138
+ const statusReport = await runCommandCapture('git', ['status', '--short', '--branch'], { cwd: rootDir })
139
+ const lines = statusReport.split(/\r?\n/)
140
+ const branchLine = lines[0] || ''
141
+ const aheadMatch = branchLine.match(/ahead (\d+)/)
142
+ const behindMatch = branchLine.match(/behind (\d+)/)
143
+ const aheadCount = aheadMatch ? parseInt(aheadMatch[1], 10) : 0
144
+ const behindCount = behindMatch ? parseInt(behindMatch[1], 10) : 0
145
+
146
+ if (aheadCount > 0) {
147
+ logWarning?.(`Local branch ${currentBranch} is ahead of upstream by ${aheadCount} commit${aheadCount === 1 ? '' : 's'}.`)
148
+ }
149
+
150
+ if (behindCount > 0) {
151
+ logProcessing?.(`Synchronizing local branch ${currentBranch} with its upstream...`)
152
+ try {
153
+ await runCommand('git', ['pull', '--ff-only'], { cwd: rootDir })
154
+ logSuccess?.('Local branch fast-forwarded with upstream changes.')
155
+ } catch (error) {
156
+ throw new Error(
157
+ `Unable to fast-forward ${currentBranch} with upstream changes. Resolve conflicts manually, then rerun the deployment.\n${error.message}`
158
+ )
159
+ }
160
+ }
161
+
162
+ if (currentBranch !== targetBranch) {
163
+ if (hasPendingChanges) {
164
+ throw new Error(
165
+ `Local repository has uncommitted changes on ${currentBranch}. Commit or stash them before switching to ${targetBranch}.`
166
+ )
167
+ }
168
+
169
+ logProcessing?.(`Switching local repository from ${currentBranch} to ${targetBranch}...`)
170
+ await runCommand('git', ['checkout', targetBranch], { cwd: rootDir })
171
+ logSuccess?.(`Checked out ${targetBranch} locally.`)
172
+ }
173
+
174
+ const statusAfterCheckout = currentBranch === targetBranch ? initialStatus : await getGitStatusFn(rootDir)
175
+
176
+ if (statusAfterCheckout.length === 0) {
177
+ await ensureCommittedChangesPushedFn(targetBranch, rootDir)
178
+ logProcessing?.('Local repository is clean. Proceeding with deployment.')
179
+ return
180
+ }
181
+
182
+ if (!hasStagedChanges(statusAfterCheckout)) {
183
+ await ensureCommittedChangesPushedFn(targetBranch, rootDir)
184
+ logProcessing?.('No staged changes detected. Unstaged or untracked files will not affect deployment. Proceeding with deployment.')
185
+ return
186
+ }
187
+
188
+ logWarning?.(`Staged changes detected on ${targetBranch}. A commit is required before deployment.`)
189
+
190
+ const { commitMessage } = await runPrompt([
191
+ {
192
+ type: 'input',
193
+ name: 'commitMessage',
194
+ message: 'Enter a commit message for pending changes before deployment',
195
+ validate: (value) => (value && value.trim().length > 0 ? true : 'Commit message cannot be empty.')
196
+ }
197
+ ])
198
+
199
+ const message = commitMessage.trim()
200
+
201
+ logProcessing?.('Committing staged changes before deployment...')
202
+ await runCommand('git', ['commit', '-m', message], { cwd: rootDir })
203
+ await runCommand('git', ['push', 'origin', targetBranch], { cwd: rootDir })
204
+ logSuccess?.(`Committed and pushed changes to origin/${targetBranch}.`)
205
+
206
+ const finalStatus = await getGitStatusFn(rootDir)
207
+
208
+ if (finalStatus.length > 0) {
209
+ throw new Error('Local repository still has uncommitted changes after commit. Aborting deployment.')
210
+ }
211
+
212
+ await ensureCommittedChangesPushedFn(targetBranch, rootDir)
213
+ logProcessing?.('Local repository is clean after committing pending changes.')
214
+ }
215
+