@wyxos/zephyr 0.2.14 → 0.2.16

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/bin/zephyr.mjs CHANGED
@@ -1,13 +1,24 @@
1
- #!/usr/bin/env node
1
+ #!/usr/bin/env node
2
2
  import { main } from '../src/index.mjs'
3
+ import { checkAndUpdateVersion } from '../src/version-checker.mjs'
4
+ import inquirer from 'inquirer'
3
5
 
4
6
  // Parse --type flag from command line arguments
5
7
  const args = process.argv.slice(2)
6
8
  const typeFlag = args.find(arg => arg.startsWith('--type='))
7
9
  const releaseType = typeFlag ? typeFlag.split('=')[1] : null
8
10
 
9
- // Pass the type to main function
10
- main(releaseType).catch((error) => {
11
- console.error(error.message)
12
- process.exit(1)
13
- })
11
+ // Check for updates and re-execute if user confirms
12
+ checkAndUpdateVersion((questions) => inquirer.prompt(questions), args)
13
+ .then((reExecuted) => {
14
+ if (reExecuted) {
15
+ // Version was updated and script re-executed, exit this process
16
+ process.exit(0)
17
+ }
18
+ // No update or user declined, continue with normal execution
19
+ return main(releaseType)
20
+ })
21
+ .catch((error) => {
22
+ console.error(error.message)
23
+ process.exit(1)
24
+ })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wyxos/zephyr",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "A streamlined deployment tool for web applications with intelligent Laravel project detection",
5
5
  "type": "module",
6
6
  "main": "./src/index.mjs",
@@ -0,0 +1,457 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { spawn } from 'node:child_process'
4
+ import process from 'node:process'
5
+
6
+ const IS_WINDOWS = process.platform === 'win32'
7
+
8
+ function isLocalPathOutsideRepo(depPath, rootDir) {
9
+ if (!depPath || typeof depPath !== 'string') {
10
+ return false
11
+ }
12
+
13
+ // Remove file: prefix if present
14
+ let cleanPath = depPath
15
+ if (depPath.startsWith('file:')) {
16
+ cleanPath = depPath.slice(5)
17
+ }
18
+
19
+ // Resolve the path relative to the root directory
20
+ const resolvedPath = path.resolve(rootDir, cleanPath)
21
+ const resolvedRoot = path.resolve(rootDir)
22
+
23
+ // Normalize paths to handle different separators
24
+ const normalizedResolved = path.normalize(resolvedPath)
25
+ const normalizedRoot = path.normalize(resolvedRoot)
26
+
27
+ // If paths are equal, it's not outside
28
+ if (normalizedResolved === normalizedRoot) {
29
+ return false
30
+ }
31
+
32
+ // Check if resolved path is outside the repository root
33
+ // Use path.relative to check if the path goes outside
34
+ const relative = path.relative(normalizedRoot, normalizedResolved)
35
+
36
+ // If relative path starts with .., it's outside the repo
37
+ // Also check if the resolved path doesn't start with the root + separator (for absolute paths)
38
+ return relative.startsWith('..') || !normalizedResolved.startsWith(normalizedRoot + path.sep)
39
+ }
40
+
41
+ async function scanPackageJsonDependencies(rootDir) {
42
+ const packageJsonPath = path.join(rootDir, 'package.json')
43
+ const localDeps = []
44
+
45
+ try {
46
+ const raw = await readFile(packageJsonPath, 'utf8')
47
+ const pkg = JSON.parse(raw)
48
+
49
+ const checkDeps = (deps, field) => {
50
+ if (!deps || typeof deps !== 'object') {
51
+ return
52
+ }
53
+
54
+ for (const [packageName, version] of Object.entries(deps)) {
55
+ if (typeof version === 'string' && version.startsWith('file:')) {
56
+ if (isLocalPathOutsideRepo(version, rootDir)) {
57
+ localDeps.push({
58
+ packageName,
59
+ path: version,
60
+ field
61
+ })
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ checkDeps(pkg.dependencies, 'dependencies')
68
+ checkDeps(pkg.devDependencies, 'devDependencies')
69
+
70
+ return localDeps
71
+ } catch (error) {
72
+ if (error.code === 'ENOENT') {
73
+ return []
74
+ }
75
+ throw error
76
+ }
77
+ }
78
+
79
+ async function scanComposerJsonDependencies(rootDir) {
80
+ const composerJsonPath = path.join(rootDir, 'composer.json')
81
+ const localDeps = []
82
+
83
+ try {
84
+ const raw = await readFile(composerJsonPath, 'utf8')
85
+ const composer = JSON.parse(raw)
86
+
87
+ // Check repositories field for local path repositories
88
+ if (composer.repositories && Array.isArray(composer.repositories)) {
89
+ for (const repo of composer.repositories) {
90
+ if (repo.type === 'path' && repo.url) {
91
+ if (isLocalPathOutsideRepo(repo.url, rootDir)) {
92
+ // Try to find which package uses this repository
93
+ // Check require and require-dev for packages that might use this repo
94
+ const repoPath = path.basename(repo.url.replace(/\/$/, ''))
95
+ const possiblePackages = []
96
+
97
+ const checkRequire = (requireObj, field) => {
98
+ if (!requireObj || typeof requireObj !== 'object') {
99
+ return
100
+ }
101
+ for (const [packageName] of Object.entries(requireObj)) {
102
+ // If package name matches the repo path or contains it, it's likely using this repo
103
+ if (packageName.includes(repoPath) || repoPath.includes(packageName.split('/').pop())) {
104
+ possiblePackages.push({ packageName, field })
105
+ }
106
+ }
107
+ }
108
+
109
+ checkRequire(composer.require, 'require')
110
+ checkRequire(composer['require-dev'], 'require-dev')
111
+
112
+ if (possiblePackages.length > 0) {
113
+ for (const { packageName, field } of possiblePackages) {
114
+ localDeps.push({
115
+ packageName,
116
+ path: repo.url,
117
+ field
118
+ })
119
+ }
120
+ } else {
121
+ // If we can't determine which package, still report the repository
122
+ localDeps.push({
123
+ packageName: repo.url,
124
+ path: repo.url,
125
+ field: 'repositories'
126
+ })
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ return localDeps
134
+ } catch (error) {
135
+ if (error.code === 'ENOENT') {
136
+ return []
137
+ }
138
+ throw error
139
+ }
140
+ }
141
+
142
+ async function fetchLatestNpmVersion(packageName) {
143
+ try {
144
+ const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`)
145
+ if (!response.ok) {
146
+ return null
147
+ }
148
+ const data = await response.json()
149
+ return data.version || null
150
+ } catch (error) {
151
+ return null
152
+ }
153
+ }
154
+
155
+ async function fetchLatestPackagistVersion(packageName) {
156
+ try {
157
+ // Packagist API v2 format
158
+ const response = await fetch(`https://repo.packagist.org/p2/${packageName}.json`)
159
+ if (!response.ok) {
160
+ return null
161
+ }
162
+ const data = await response.json()
163
+ if (data.packages && data.packages[packageName] && data.packages[packageName].length > 0) {
164
+ // Get the latest version (first in array is usually latest)
165
+ const latest = data.packages[packageName][0]
166
+ return latest.version || null
167
+ }
168
+ return null
169
+ } catch (error) {
170
+ return null
171
+ }
172
+ }
173
+
174
+ async function updatePackageJsonDependency(rootDir, packageName, newVersion, field) {
175
+ const packageJsonPath = path.join(rootDir, 'package.json')
176
+ const raw = await readFile(packageJsonPath, 'utf8')
177
+ const pkg = JSON.parse(raw)
178
+
179
+ if (!pkg[field]) {
180
+ pkg[field] = {}
181
+ }
182
+
183
+ pkg[field][packageName] = `^${newVersion}`
184
+
185
+ const updatedContent = JSON.stringify(pkg, null, 2) + '\n'
186
+ await writeFile(packageJsonPath, updatedContent, 'utf8')
187
+ }
188
+
189
+ async function updateComposerJsonDependency(rootDir, packageName, newVersion, field) {
190
+ const composerJsonPath = path.join(rootDir, 'composer.json')
191
+ const raw = await readFile(composerJsonPath, 'utf8')
192
+ const composer = JSON.parse(raw)
193
+
194
+ if (field === 'repositories') {
195
+ // Remove the local repository entry
196
+ if (composer.repositories && Array.isArray(composer.repositories)) {
197
+ composer.repositories = composer.repositories.filter(
198
+ (repo) => !(repo.type === 'path' && repo.url === packageName)
199
+ )
200
+ }
201
+ // Update the dependency version in require or require-dev
202
+ // We need to find which field contains this package
203
+ if (composer.require && composer.require[packageName]) {
204
+ composer.require[packageName] = `^${newVersion}`
205
+ } else if (composer['require-dev'] && composer['require-dev'][packageName]) {
206
+ composer['require-dev'][packageName] = `^${newVersion}`
207
+ }
208
+ } else {
209
+ if (!composer[field]) {
210
+ composer[field] = {}
211
+ }
212
+ composer[field][packageName] = `^${newVersion}`
213
+ }
214
+
215
+ const updatedContent = JSON.stringify(composer, null, 2) + '\n'
216
+ await writeFile(composerJsonPath, updatedContent, 'utf8')
217
+ }
218
+
219
+ async function runCommand(command, args, { cwd = process.cwd(), capture = false } = {}) {
220
+ return new Promise((resolve, reject) => {
221
+ const spawnOptions = {
222
+ stdio: capture ? ['ignore', 'pipe', 'pipe'] : 'inherit',
223
+ cwd
224
+ }
225
+
226
+ if (IS_WINDOWS && command !== 'git') {
227
+ spawnOptions.shell = true
228
+ }
229
+
230
+ const child = spawn(command, args, spawnOptions)
231
+ let stdout = ''
232
+ let stderr = ''
233
+
234
+ if (capture) {
235
+ child.stdout.on('data', (chunk) => {
236
+ stdout += chunk
237
+ })
238
+
239
+ child.stderr.on('data', (chunk) => {
240
+ stderr += chunk
241
+ })
242
+ }
243
+
244
+ child.on('error', reject)
245
+ child.on('close', (code) => {
246
+ if (code === 0) {
247
+ resolve(capture ? { stdout: stdout.trim(), stderr: stderr.trim() } : undefined)
248
+ } else {
249
+ const error = new Error(`Command failed (${code}): ${command} ${args.join(' ')}`)
250
+ if (capture) {
251
+ error.stdout = stdout
252
+ error.stderr = stderr
253
+ }
254
+ error.exitCode = code
255
+ reject(error)
256
+ }
257
+ })
258
+ })
259
+ }
260
+
261
+ async function getGitStatus(rootDir) {
262
+ try {
263
+ const result = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
264
+ return result.stdout || ''
265
+ } catch (error) {
266
+ return ''
267
+ }
268
+ }
269
+
270
+ function hasStagedChanges(statusOutput) {
271
+ if (!statusOutput || statusOutput.length === 0) {
272
+ return false
273
+ }
274
+
275
+ const lines = statusOutput.split('\n').filter((line) => line.trim().length > 0)
276
+
277
+ return lines.some((line) => {
278
+ const firstChar = line[0]
279
+ return firstChar && firstChar !== ' ' && firstChar !== '?'
280
+ })
281
+ }
282
+
283
+ async function commitDependencyUpdates(rootDir, updatedFiles, logFn) {
284
+ try {
285
+ // Check if we're in a git repository
286
+ await runCommand('git', ['rev-parse', '--is-inside-work-tree'], { capture: true, cwd: rootDir })
287
+ } catch {
288
+ // Not a git repository, skip commit
289
+ return false
290
+ }
291
+
292
+ const status = await getGitStatus(rootDir)
293
+
294
+ // Stage the updated files
295
+ for (const file of updatedFiles) {
296
+ try {
297
+ await runCommand('git', ['add', file], { cwd: rootDir })
298
+ } catch {
299
+ // File might not exist or not be tracked, continue
300
+ }
301
+ }
302
+
303
+ const newStatus = await getGitStatus(rootDir)
304
+ if (!hasStagedChanges(newStatus)) {
305
+ return false
306
+ }
307
+
308
+ // Build commit message
309
+ const fileList = updatedFiles.map(f => path.basename(f)).join(', ')
310
+ const commitMessage = `chore: update local file dependencies to online versions (${fileList})`
311
+
312
+ if (logFn) {
313
+ logFn('Committing dependency updates...')
314
+ }
315
+
316
+ await runCommand('git', ['commit', '-m', commitMessage], { cwd: rootDir })
317
+
318
+ if (logFn) {
319
+ logFn('Dependency updates committed.')
320
+ }
321
+
322
+ return true
323
+ }
324
+
325
+ async function validateLocalDependencies(rootDir, promptFn, logFn = null) {
326
+ const packageDeps = await scanPackageJsonDependencies(rootDir)
327
+ const composerDeps = await scanComposerJsonDependencies(rootDir)
328
+
329
+ const allDeps = [...packageDeps, ...composerDeps]
330
+
331
+ if (allDeps.length === 0) {
332
+ return
333
+ }
334
+
335
+ // Fetch latest versions for all dependencies
336
+ const depsWithVersions = await Promise.all(
337
+ allDeps.map(async (dep) => {
338
+ let latestVersion = null
339
+ if (dep.field === 'dependencies' || dep.field === 'devDependencies') {
340
+ latestVersion = await fetchLatestNpmVersion(dep.packageName)
341
+ } else if (dep.field === 'require' || dep.field === 'require-dev') {
342
+ latestVersion = await fetchLatestPackagistVersion(dep.packageName)
343
+ } else if (dep.field === 'repositories') {
344
+ // For repositories, try to extract package name and fetch from Packagist
345
+ // The packageName might be the path, so we need to handle this differently
346
+ // For now, we'll show the path but can't fetch version
347
+ }
348
+
349
+ return {
350
+ ...dep,
351
+ latestVersion
352
+ }
353
+ })
354
+ )
355
+
356
+ // Build warning messages
357
+ const messages = depsWithVersions.map((dep) => {
358
+ const versionInfo = dep.latestVersion
359
+ ? ` Latest version available: ${dep.latestVersion}.`
360
+ : ' Latest version could not be determined.'
361
+ return `Dependency '${dep.packageName}' is pointing to a local path outside the repository: ${dep.path}.${versionInfo}`
362
+ })
363
+
364
+ // Prompt user
365
+ const { shouldUpdate } = await promptFn([
366
+ {
367
+ type: 'confirm',
368
+ name: 'shouldUpdate',
369
+ message: `Found ${allDeps.length} local file dependency/dependencies pointing outside the repository:\n\n${messages.join('\n\n')}\n\nUpdate to latest version?`,
370
+ default: true
371
+ }
372
+ ])
373
+
374
+ if (!shouldUpdate) {
375
+ throw new Error('Release cancelled: local file dependencies must be updated before release.')
376
+ }
377
+
378
+ // Track which files were updated
379
+ const updatedFiles = new Set()
380
+
381
+ // Update dependencies
382
+ for (const dep of depsWithVersions) {
383
+ if (!dep.latestVersion) {
384
+ continue
385
+ }
386
+
387
+ if (dep.field === 'dependencies' || dep.field === 'devDependencies') {
388
+ await updatePackageJsonDependency(rootDir, dep.packageName, dep.latestVersion, dep.field)
389
+ updatedFiles.add('package.json')
390
+ } else if (dep.field === 'require' || dep.field === 'require-dev') {
391
+ await updateComposerJsonDependency(rootDir, dep.packageName, dep.latestVersion, dep.field)
392
+ updatedFiles.add('composer.json')
393
+ } else if (dep.field === 'repositories') {
394
+ // For repositories, we need to remove the repository entry
395
+ // But we still need to update the dependency version
396
+ // This is more complex, so for now we'll just update if we can find the package
397
+ const composerJsonPath = path.join(rootDir, 'composer.json')
398
+ const raw = await readFile(composerJsonPath, 'utf8')
399
+ const composer = JSON.parse(raw)
400
+
401
+ // Try to find which package uses this repository
402
+ let packageToUpdate = null
403
+ let fieldToUpdate = null
404
+
405
+ if (composer.require) {
406
+ for (const [pkgName] of Object.entries(composer.require)) {
407
+ if (pkgName.includes(dep.packageName.split('/').pop()) || dep.packageName.includes(pkgName.split('/').pop())) {
408
+ packageToUpdate = pkgName
409
+ fieldToUpdate = 'require'
410
+ break
411
+ }
412
+ }
413
+ }
414
+
415
+ if (!packageToUpdate && composer['require-dev']) {
416
+ for (const [pkgName] of Object.entries(composer['require-dev'])) {
417
+ if (pkgName.includes(dep.packageName.split('/').pop()) || dep.packageName.includes(pkgName.split('/').pop())) {
418
+ packageToUpdate = pkgName
419
+ fieldToUpdate = 'require-dev'
420
+ break
421
+ }
422
+ }
423
+ }
424
+
425
+ if (packageToUpdate && fieldToUpdate) {
426
+ await updateComposerJsonDependency(rootDir, packageToUpdate, dep.latestVersion, fieldToUpdate)
427
+ // Also remove the repository entry
428
+ const updatedRaw = await readFile(composerJsonPath, 'utf8')
429
+ const updatedComposer = JSON.parse(updatedRaw)
430
+ if (updatedComposer.repositories && Array.isArray(updatedComposer.repositories)) {
431
+ updatedComposer.repositories = updatedComposer.repositories.filter(
432
+ (repo) => !(repo.type === 'path' && repo.url === dep.path)
433
+ )
434
+ const updatedContent = JSON.stringify(updatedComposer, null, 2) + '\n'
435
+ await writeFile(composerJsonPath, updatedContent, 'utf8')
436
+ }
437
+ updatedFiles.add('composer.json')
438
+ }
439
+ }
440
+ }
441
+
442
+ // Commit the changes if any files were updated
443
+ if (updatedFiles.size > 0) {
444
+ await commitDependencyUpdates(rootDir, Array.from(updatedFiles), logFn)
445
+ }
446
+ }
447
+
448
+ export {
449
+ scanPackageJsonDependencies,
450
+ scanComposerJsonDependencies,
451
+ fetchLatestNpmVersion,
452
+ fetchLatestPackagistVersion,
453
+ updatePackageJsonDependency,
454
+ updateComposerJsonDependency,
455
+ isLocalPathOutsideRepo,
456
+ validateLocalDependencies
457
+ }
package/src/index.mjs CHANGED
@@ -9,6 +9,7 @@ import inquirer from 'inquirer'
9
9
  import { NodeSSH } from 'node-ssh'
10
10
  import { releaseNode } from './release-node.mjs'
11
11
  import { releasePackagist } from './release-packagist.mjs'
12
+ import { validateLocalDependencies } from './dependency-scanner.mjs'
12
13
 
13
14
  const IS_WINDOWS = process.platform === 'win32'
14
15
 
@@ -1249,7 +1250,7 @@ async function runRemoteTasks(config, options = {}) {
1249
1250
  if (isLaravel) {
1250
1251
  logProcessing('Running Laravel tests locally...')
1251
1252
  try {
1252
- await runCommand('php', ['artisan', 'test'], { cwd: rootDir })
1253
+ await runCommand('php', ['artisan', 'test', '--compact'], { cwd: rootDir })
1253
1254
  logSuccess('Local tests passed.')
1254
1255
  } catch (error) {
1255
1256
  throw new Error(`Local tests failed. Fix test failures before deploying. ${error.message}`)
@@ -1906,6 +1907,17 @@ async function main(releaseType = null) {
1906
1907
  await ensureGitignoreEntry(rootDir)
1907
1908
  await ensureProjectReleaseScript(rootDir)
1908
1909
 
1910
+ // Validate dependencies if package.json or composer.json exists
1911
+ const packageJsonPath = path.join(rootDir, 'package.json')
1912
+ const composerJsonPath = path.join(rootDir, 'composer.json')
1913
+ const hasPackageJson = await fs.access(packageJsonPath).then(() => true).catch(() => false)
1914
+ const hasComposerJson = await fs.access(composerJsonPath).then(() => true).catch(() => false)
1915
+
1916
+ if (hasPackageJson || hasComposerJson) {
1917
+ logProcessing('Validating dependencies...')
1918
+ await validateLocalDependencies(rootDir, runPrompt, logSuccess)
1919
+ }
1920
+
1909
1921
  // Load servers first (they may be migrated)
1910
1922
  const servers = await loadServers()
1911
1923
  // Load project config with servers for migration
@@ -6,6 +6,8 @@ import fs from 'node:fs'
6
6
  import path from 'node:path'
7
7
  import process from 'node:process'
8
8
  import chalk from 'chalk'
9
+ import inquirer from 'inquirer'
10
+ import { validateLocalDependencies } from './dependency-scanner.mjs'
9
11
 
10
12
  const IS_WINDOWS = process.platform === 'win32'
11
13
 
@@ -580,6 +582,9 @@ export async function releaseNode() {
580
582
  logStep('Reading package metadata...')
581
583
  const pkg = await readPackage(rootDir)
582
584
 
585
+ logStep('Validating dependencies...')
586
+ await validateLocalDependencies(rootDir, (questions) => inquirer.prompt(questions), logSuccess)
587
+
583
588
  logStep('Checking working tree status...')
584
589
  await ensureCleanWorkingTree(rootDir)
585
590
 
@@ -6,6 +6,8 @@ import fs from 'node:fs'
6
6
  import path from 'node:path'
7
7
  import process from 'node:process'
8
8
  import semver from 'semver'
9
+ import inquirer from 'inquirer'
10
+ import { validateLocalDependencies } from './dependency-scanner.mjs'
9
11
 
10
12
  const STEP_PREFIX = '→'
11
13
  const OK_PREFIX = '✔'
@@ -303,7 +305,7 @@ async function runTests(skipTests, composer, rootDir = process.cwd()) {
303
305
  }, 200)
304
306
 
305
307
  if (hasArtisanFile) {
306
- await runCommand('php', ['artisan', 'test'], { capture: true, cwd: rootDir })
308
+ await runCommand('php', ['artisan', 'test', '--compact'], { capture: true, cwd: rootDir })
307
309
  } else if (hasTestScript) {
308
310
  await runCommand('composer', ['test'], { capture: true, cwd: rootDir })
309
311
  }
@@ -384,6 +386,9 @@ export async function releasePackagist() {
384
386
  throw new Error('composer.json does not have a version field. Add "version": "0.0.0" to composer.json.')
385
387
  }
386
388
 
389
+ logStep('Validating dependencies...')
390
+ await validateLocalDependencies(rootDir, (questions) => inquirer.prompt(questions), logSuccess)
391
+
387
392
  logStep('Checking working tree status...')
388
393
  await ensureCleanWorkingTree(rootDir)
389
394
 
@@ -0,0 +1,126 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { fileURLToPath } from 'node:url'
3
+ import path from 'node:path'
4
+ import { spawn } from 'node:child_process'
5
+ import process from 'node:process'
6
+ import semver from 'semver'
7
+
8
+ const IS_WINDOWS = process.platform === 'win32'
9
+
10
+ async function getCurrentVersion() {
11
+ try {
12
+ // Try to get version from package.json
13
+ // When running via npx, the package.json is in the installed package directory
14
+ const packageJsonPath = path.resolve(
15
+ path.dirname(fileURLToPath(import.meta.url)),
16
+ '..',
17
+ 'package.json'
18
+ )
19
+ const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'))
20
+ return packageJson.version
21
+ } catch (error) {
22
+ // If we can't read package.json, return null
23
+ return null
24
+ }
25
+ }
26
+
27
+ async function getLatestVersion() {
28
+ try {
29
+ const response = await fetch('https://registry.npmjs.org/@wyxos/zephyr/latest')
30
+ if (!response.ok) {
31
+ return null
32
+ }
33
+ const data = await response.json()
34
+ return data.version || null
35
+ } catch (error) {
36
+ return null
37
+ }
38
+ }
39
+
40
+ function isNewerVersionAvailable(current, latest) {
41
+ if (!current || !latest) {
42
+ return false
43
+ }
44
+
45
+ // Use semver to properly compare versions
46
+ try {
47
+ return semver.gt(latest, current)
48
+ } catch (error) {
49
+ // If semver comparison fails, fall back to simple string comparison
50
+ return latest !== current
51
+ }
52
+ }
53
+
54
+ async function reExecuteWithLatest(args) {
55
+ // Re-execute with npx @wyxos/zephyr@latest
56
+ const command = IS_WINDOWS ? 'npx.cmd' : 'npx'
57
+ const npxArgs = ['@wyxos/zephyr@latest', ...args]
58
+
59
+ return new Promise((resolve, reject) => {
60
+ const child = spawn(command, npxArgs, {
61
+ stdio: 'inherit',
62
+ shell: IS_WINDOWS
63
+ })
64
+
65
+ child.on('error', reject)
66
+ child.on('close', (code) => {
67
+ if (code === 0) {
68
+ resolve()
69
+ } else {
70
+ reject(new Error(`Command exited with code ${code}`))
71
+ }
72
+ })
73
+ })
74
+ }
75
+
76
+ export async function checkAndUpdateVersion(promptFn, args) {
77
+ try {
78
+ // Skip check if already running @latest (detected via environment or process)
79
+ // When npx runs @latest, the version should already be latest
80
+ const isRunningLatest = process.env.npm_config_user_config?.includes('@latest') ||
81
+ process.argv.some(arg => arg.includes('@latest'))
82
+
83
+ if (isRunningLatest) {
84
+ return false
85
+ }
86
+
87
+ const currentVersion = await getCurrentVersion()
88
+ if (!currentVersion) {
89
+ // Can't determine current version, skip check
90
+ return false
91
+ }
92
+
93
+ const latestVersion = await getLatestVersion()
94
+ if (!latestVersion) {
95
+ // Can't fetch latest version, skip check
96
+ return false
97
+ }
98
+
99
+ if (!isNewerVersionAvailable(currentVersion, latestVersion)) {
100
+ // Already on latest or newer
101
+ return false
102
+ }
103
+
104
+ // Newer version available, prompt user
105
+ const { shouldUpdate } = await promptFn([
106
+ {
107
+ type: 'confirm',
108
+ name: 'shouldUpdate',
109
+ message: `A new version of @wyxos/zephyr is available (${latestVersion}). You are currently on ${currentVersion}. Update and continue?`,
110
+ default: true
111
+ }
112
+ ])
113
+
114
+ if (!shouldUpdate) {
115
+ return false
116
+ }
117
+
118
+ // User confirmed, re-execute with latest version
119
+ await reExecuteWithLatest(args)
120
+ return true // Indicates we've re-executed, so the current process should exit
121
+ } catch (error) {
122
+ // If version check fails, just continue with current version
123
+ // Don't block the user from using the tool
124
+ return false
125
+ }
126
+ }