@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 +17 -6
- package/package.json +1 -1
- package/src/dependency-scanner.mjs +457 -0
- package/src/index.mjs +13 -1
- package/src/release-node.mjs +5 -0
- package/src/release-packagist.mjs +6 -1
- package/src/version-checker.mjs +126 -0
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
|
-
//
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
@@ -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
|
package/src/release-node.mjs
CHANGED
|
@@ -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
|
+
}
|