@wyxos/zephyr 0.2.14 → 0.2.15

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.15",
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,336 @@
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ function isLocalPathOutsideRepo(depPath, rootDir) {
5
+ if (!depPath || typeof depPath !== 'string') {
6
+ return false
7
+ }
8
+
9
+ // Remove file: prefix if present
10
+ let cleanPath = depPath
11
+ if (depPath.startsWith('file:')) {
12
+ cleanPath = depPath.slice(5)
13
+ }
14
+
15
+ // Resolve the path relative to the root directory
16
+ const resolvedPath = path.resolve(rootDir, cleanPath)
17
+ const resolvedRoot = path.resolve(rootDir)
18
+
19
+ // Normalize paths to handle different separators
20
+ const normalizedResolved = path.normalize(resolvedPath)
21
+ const normalizedRoot = path.normalize(resolvedRoot)
22
+
23
+ // If paths are equal, it's not outside
24
+ if (normalizedResolved === normalizedRoot) {
25
+ return false
26
+ }
27
+
28
+ // Check if resolved path is outside the repository root
29
+ // Use path.relative to check if the path goes outside
30
+ const relative = path.relative(normalizedRoot, normalizedResolved)
31
+
32
+ // If relative path starts with .., it's outside the repo
33
+ // Also check if the resolved path doesn't start with the root + separator (for absolute paths)
34
+ return relative.startsWith('..') || !normalizedResolved.startsWith(normalizedRoot + path.sep)
35
+ }
36
+
37
+ async function scanPackageJsonDependencies(rootDir) {
38
+ const packageJsonPath = path.join(rootDir, 'package.json')
39
+ const localDeps = []
40
+
41
+ try {
42
+ const raw = await readFile(packageJsonPath, 'utf8')
43
+ const pkg = JSON.parse(raw)
44
+
45
+ const checkDeps = (deps, field) => {
46
+ if (!deps || typeof deps !== 'object') {
47
+ return
48
+ }
49
+
50
+ for (const [packageName, version] of Object.entries(deps)) {
51
+ if (typeof version === 'string' && version.startsWith('file:')) {
52
+ if (isLocalPathOutsideRepo(version, rootDir)) {
53
+ localDeps.push({
54
+ packageName,
55
+ path: version,
56
+ field
57
+ })
58
+ }
59
+ }
60
+ }
61
+ }
62
+
63
+ checkDeps(pkg.dependencies, 'dependencies')
64
+ checkDeps(pkg.devDependencies, 'devDependencies')
65
+
66
+ return localDeps
67
+ } catch (error) {
68
+ if (error.code === 'ENOENT') {
69
+ return []
70
+ }
71
+ throw error
72
+ }
73
+ }
74
+
75
+ async function scanComposerJsonDependencies(rootDir) {
76
+ const composerJsonPath = path.join(rootDir, 'composer.json')
77
+ const localDeps = []
78
+
79
+ try {
80
+ const raw = await readFile(composerJsonPath, 'utf8')
81
+ const composer = JSON.parse(raw)
82
+
83
+ // Check repositories field for local path repositories
84
+ if (composer.repositories && Array.isArray(composer.repositories)) {
85
+ for (const repo of composer.repositories) {
86
+ if (repo.type === 'path' && repo.url) {
87
+ if (isLocalPathOutsideRepo(repo.url, rootDir)) {
88
+ // Try to find which package uses this repository
89
+ // Check require and require-dev for packages that might use this repo
90
+ const repoPath = path.basename(repo.url.replace(/\/$/, ''))
91
+ const possiblePackages = []
92
+
93
+ const checkRequire = (requireObj, field) => {
94
+ if (!requireObj || typeof requireObj !== 'object') {
95
+ return
96
+ }
97
+ for (const [packageName] of Object.entries(requireObj)) {
98
+ // If package name matches the repo path or contains it, it's likely using this repo
99
+ if (packageName.includes(repoPath) || repoPath.includes(packageName.split('/').pop())) {
100
+ possiblePackages.push({ packageName, field })
101
+ }
102
+ }
103
+ }
104
+
105
+ checkRequire(composer.require, 'require')
106
+ checkRequire(composer['require-dev'], 'require-dev')
107
+
108
+ if (possiblePackages.length > 0) {
109
+ for (const { packageName, field } of possiblePackages) {
110
+ localDeps.push({
111
+ packageName,
112
+ path: repo.url,
113
+ field
114
+ })
115
+ }
116
+ } else {
117
+ // If we can't determine which package, still report the repository
118
+ localDeps.push({
119
+ packageName: repo.url,
120
+ path: repo.url,
121
+ field: 'repositories'
122
+ })
123
+ }
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ return localDeps
130
+ } catch (error) {
131
+ if (error.code === 'ENOENT') {
132
+ return []
133
+ }
134
+ throw error
135
+ }
136
+ }
137
+
138
+ async function fetchLatestNpmVersion(packageName) {
139
+ try {
140
+ const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`)
141
+ if (!response.ok) {
142
+ return null
143
+ }
144
+ const data = await response.json()
145
+ return data.version || null
146
+ } catch (error) {
147
+ return null
148
+ }
149
+ }
150
+
151
+ async function fetchLatestPackagistVersion(packageName) {
152
+ try {
153
+ // Packagist API v2 format
154
+ const response = await fetch(`https://repo.packagist.org/p2/${packageName}.json`)
155
+ if (!response.ok) {
156
+ return null
157
+ }
158
+ const data = await response.json()
159
+ if (data.packages && data.packages[packageName] && data.packages[packageName].length > 0) {
160
+ // Get the latest version (first in array is usually latest)
161
+ const latest = data.packages[packageName][0]
162
+ return latest.version || null
163
+ }
164
+ return null
165
+ } catch (error) {
166
+ return null
167
+ }
168
+ }
169
+
170
+ async function updatePackageJsonDependency(rootDir, packageName, newVersion, field) {
171
+ const packageJsonPath = path.join(rootDir, 'package.json')
172
+ const raw = await readFile(packageJsonPath, 'utf8')
173
+ const pkg = JSON.parse(raw)
174
+
175
+ if (!pkg[field]) {
176
+ pkg[field] = {}
177
+ }
178
+
179
+ pkg[field][packageName] = `^${newVersion}`
180
+
181
+ const updatedContent = JSON.stringify(pkg, null, 2) + '\n'
182
+ await writeFile(packageJsonPath, updatedContent, 'utf8')
183
+ }
184
+
185
+ async function updateComposerJsonDependency(rootDir, packageName, newVersion, field) {
186
+ const composerJsonPath = path.join(rootDir, 'composer.json')
187
+ const raw = await readFile(composerJsonPath, 'utf8')
188
+ const composer = JSON.parse(raw)
189
+
190
+ if (field === 'repositories') {
191
+ // Remove the local repository entry
192
+ if (composer.repositories && Array.isArray(composer.repositories)) {
193
+ composer.repositories = composer.repositories.filter(
194
+ (repo) => !(repo.type === 'path' && repo.url === packageName)
195
+ )
196
+ }
197
+ // Update the dependency version in require or require-dev
198
+ // We need to find which field contains this package
199
+ if (composer.require && composer.require[packageName]) {
200
+ composer.require[packageName] = `^${newVersion}`
201
+ } else if (composer['require-dev'] && composer['require-dev'][packageName]) {
202
+ composer['require-dev'][packageName] = `^${newVersion}`
203
+ }
204
+ } else {
205
+ if (!composer[field]) {
206
+ composer[field] = {}
207
+ }
208
+ composer[field][packageName] = `^${newVersion}`
209
+ }
210
+
211
+ const updatedContent = JSON.stringify(composer, null, 2) + '\n'
212
+ await writeFile(composerJsonPath, updatedContent, 'utf8')
213
+ }
214
+
215
+ async function validateLocalDependencies(rootDir, promptFn) {
216
+ const packageDeps = await scanPackageJsonDependencies(rootDir)
217
+ const composerDeps = await scanComposerJsonDependencies(rootDir)
218
+
219
+ const allDeps = [...packageDeps, ...composerDeps]
220
+
221
+ if (allDeps.length === 0) {
222
+ return
223
+ }
224
+
225
+ // Fetch latest versions for all dependencies
226
+ const depsWithVersions = await Promise.all(
227
+ allDeps.map(async (dep) => {
228
+ let latestVersion = null
229
+ if (dep.field === 'dependencies' || dep.field === 'devDependencies') {
230
+ latestVersion = await fetchLatestNpmVersion(dep.packageName)
231
+ } else if (dep.field === 'require' || dep.field === 'require-dev') {
232
+ latestVersion = await fetchLatestPackagistVersion(dep.packageName)
233
+ } else if (dep.field === 'repositories') {
234
+ // For repositories, try to extract package name and fetch from Packagist
235
+ // The packageName might be the path, so we need to handle this differently
236
+ // For now, we'll show the path but can't fetch version
237
+ }
238
+
239
+ return {
240
+ ...dep,
241
+ latestVersion
242
+ }
243
+ })
244
+ )
245
+
246
+ // Build warning messages
247
+ const messages = depsWithVersions.map((dep) => {
248
+ const versionInfo = dep.latestVersion
249
+ ? ` Latest version available: ${dep.latestVersion}.`
250
+ : ' Latest version could not be determined.'
251
+ return `Dependency '${dep.packageName}' is pointing to a local path outside the repository: ${dep.path}.${versionInfo}`
252
+ })
253
+
254
+ // Prompt user
255
+ const { shouldUpdate } = await promptFn([
256
+ {
257
+ type: 'confirm',
258
+ name: 'shouldUpdate',
259
+ message: `Found ${allDeps.length} local file dependency/dependencies pointing outside the repository:\n\n${messages.join('\n\n')}\n\nUpdate to latest version?`,
260
+ default: true
261
+ }
262
+ ])
263
+
264
+ if (!shouldUpdate) {
265
+ throw new Error('Release cancelled: local file dependencies must be updated before release.')
266
+ }
267
+
268
+ // Update dependencies
269
+ for (const dep of depsWithVersions) {
270
+ if (!dep.latestVersion) {
271
+ continue
272
+ }
273
+
274
+ if (dep.field === 'dependencies' || dep.field === 'devDependencies') {
275
+ await updatePackageJsonDependency(rootDir, dep.packageName, dep.latestVersion, dep.field)
276
+ } else if (dep.field === 'require' || dep.field === 'require-dev') {
277
+ await updateComposerJsonDependency(rootDir, dep.packageName, dep.latestVersion, dep.field)
278
+ } else if (dep.field === 'repositories') {
279
+ // For repositories, we need to remove the repository entry
280
+ // But we still need to update the dependency version
281
+ // This is more complex, so for now we'll just update if we can find the package
282
+ const composerJsonPath = path.join(rootDir, 'composer.json')
283
+ const raw = await readFile(composerJsonPath, 'utf8')
284
+ const composer = JSON.parse(raw)
285
+
286
+ // Try to find which package uses this repository
287
+ let packageToUpdate = null
288
+ let fieldToUpdate = null
289
+
290
+ if (composer.require) {
291
+ for (const [pkgName] of Object.entries(composer.require)) {
292
+ if (pkgName.includes(dep.packageName.split('/').pop()) || dep.packageName.includes(pkgName.split('/').pop())) {
293
+ packageToUpdate = pkgName
294
+ fieldToUpdate = 'require'
295
+ break
296
+ }
297
+ }
298
+ }
299
+
300
+ if (!packageToUpdate && composer['require-dev']) {
301
+ for (const [pkgName] of Object.entries(composer['require-dev'])) {
302
+ if (pkgName.includes(dep.packageName.split('/').pop()) || dep.packageName.includes(pkgName.split('/').pop())) {
303
+ packageToUpdate = pkgName
304
+ fieldToUpdate = 'require-dev'
305
+ break
306
+ }
307
+ }
308
+ }
309
+
310
+ if (packageToUpdate && fieldToUpdate) {
311
+ await updateComposerJsonDependency(rootDir, packageToUpdate, dep.latestVersion, fieldToUpdate)
312
+ // Also remove the repository entry
313
+ const updatedRaw = await readFile(composerJsonPath, 'utf8')
314
+ const updatedComposer = JSON.parse(updatedRaw)
315
+ if (updatedComposer.repositories && Array.isArray(updatedComposer.repositories)) {
316
+ updatedComposer.repositories = updatedComposer.repositories.filter(
317
+ (repo) => !(repo.type === 'path' && repo.url === dep.path)
318
+ )
319
+ const updatedContent = JSON.stringify(updatedComposer, null, 2) + '\n'
320
+ await writeFile(composerJsonPath, updatedContent, 'utf8')
321
+ }
322
+ }
323
+ }
324
+ }
325
+ }
326
+
327
+ export {
328
+ scanPackageJsonDependencies,
329
+ scanComposerJsonDependencies,
330
+ fetchLatestNpmVersion,
331
+ fetchLatestPackagistVersion,
332
+ updatePackageJsonDependency,
333
+ updateComposerJsonDependency,
334
+ isLocalPathOutsideRepo,
335
+ validateLocalDependencies
336
+ }
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)
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))
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))
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
+ }