@wyxos/zephyr 0.2.21 → 0.2.23

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.
@@ -1,433 +1,412 @@
1
- import { readFile, writeFile } from 'node:fs/promises'
2
- import path from 'node:path'
3
- import process from 'node:process'
4
- import chalk from 'chalk'
5
- import { runCommand as runCommandBase, runCommandCapture as runCommandCaptureBase } from './utils/command.mjs'
6
-
7
- function isLocalPathOutsideRepo(depPath, rootDir) {
8
- if (!depPath || typeof depPath !== 'string') {
9
- return false
10
- }
11
-
12
- // Remove file: prefix if present
13
- let cleanPath = depPath
14
- if (depPath.startsWith('file:')) {
15
- cleanPath = depPath.slice(5)
16
- }
17
-
18
- // Resolve the path relative to the root directory
19
- const resolvedPath = path.resolve(rootDir, cleanPath)
20
- const resolvedRoot = path.resolve(rootDir)
21
-
22
- // Normalize paths to handle different separators
23
- const normalizedResolved = path.normalize(resolvedPath)
24
- const normalizedRoot = path.normalize(resolvedRoot)
25
-
26
- // If paths are equal, it's not outside
27
- if (normalizedResolved === normalizedRoot) {
28
- return false
29
- }
30
-
31
- // Check if resolved path is outside the repository root
32
- // Use path.relative to check if the path goes outside
33
- const relative = path.relative(normalizedRoot, normalizedResolved)
34
-
35
- // If relative path starts with .., it's outside the repo
36
- // Also check if the resolved path doesn't start with the root + separator (for absolute paths)
37
- return relative.startsWith('..') || !normalizedResolved.startsWith(normalizedRoot + path.sep)
38
- }
39
-
40
- async function scanPackageJsonDependencies(rootDir) {
41
- const packageJsonPath = path.join(rootDir, 'package.json')
42
- const localDeps = []
43
-
44
- try {
45
- const raw = await readFile(packageJsonPath, 'utf8')
46
- const pkg = JSON.parse(raw)
47
-
48
- const checkDeps = (deps, field) => {
49
- if (!deps || typeof deps !== 'object') {
50
- return
51
- }
52
-
53
- for (const [packageName, version] of Object.entries(deps)) {
54
- if (typeof version === 'string' && version.startsWith('file:')) {
55
- if (isLocalPathOutsideRepo(version, rootDir)) {
56
- localDeps.push({
57
- packageName,
58
- path: version,
59
- field
60
- })
61
- }
62
- }
63
- }
64
- }
65
-
66
- checkDeps(pkg.dependencies, 'dependencies')
67
- checkDeps(pkg.devDependencies, 'devDependencies')
68
-
69
- return localDeps
70
- } catch (error) {
71
- if (error.code === 'ENOENT') {
72
- return []
73
- }
74
- throw error
75
- }
76
- }
77
-
78
- async function scanComposerJsonDependencies(rootDir) {
79
- const composerJsonPath = path.join(rootDir, 'composer.json')
80
- const localDeps = []
81
-
82
- try {
83
- const raw = await readFile(composerJsonPath, 'utf8')
84
- const composer = JSON.parse(raw)
85
-
86
- // Check repositories field for local path repositories
87
- if (composer.repositories && Array.isArray(composer.repositories)) {
88
- for (const repo of composer.repositories) {
89
- if (repo.type === 'path' && repo.url) {
90
- if (isLocalPathOutsideRepo(repo.url, rootDir)) {
91
- // Try to find which package uses this repository
92
- // Check require and require-dev for packages that might use this repo
93
- const repoPath = path.basename(repo.url.replace(/\/$/, ''))
94
- const possiblePackages = []
95
-
96
- const checkRequire = (requireObj, field) => {
97
- if (!requireObj || typeof requireObj !== 'object') {
98
- return
99
- }
100
- for (const [packageName] of Object.entries(requireObj)) {
101
- // If package name matches the repo path or contains it, it's likely using this repo
102
- if (packageName.includes(repoPath) || repoPath.includes(packageName.split('/').pop())) {
103
- possiblePackages.push({ packageName, field })
104
- }
105
- }
106
- }
107
-
108
- checkRequire(composer.require, 'require')
109
- checkRequire(composer['require-dev'], 'require-dev')
110
-
111
- if (possiblePackages.length > 0) {
112
- for (const { packageName, field } of possiblePackages) {
113
- localDeps.push({
114
- packageName,
115
- path: repo.url,
116
- field
117
- })
118
- }
119
- } else {
120
- // If we can't determine which package, still report the repository
121
- localDeps.push({
122
- packageName: repo.url,
123
- path: repo.url,
124
- field: 'repositories'
125
- })
126
- }
127
- }
128
- }
129
- }
130
- }
131
-
132
- return localDeps
133
- } catch (error) {
134
- if (error.code === 'ENOENT') {
135
- return []
136
- }
137
- throw error
138
- }
139
- }
140
-
141
- async function fetchLatestNpmVersion(packageName) {
142
- try {
143
- const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`)
144
- if (!response.ok) {
145
- return null
146
- }
147
- const data = await response.json()
148
- return data.version || null
149
- } catch (_error) {
150
- return null
151
- }
152
- }
153
-
154
- async function fetchLatestPackagistVersion(packageName) {
155
- try {
156
- // Packagist API v2 format
157
- const response = await fetch(`https://repo.packagist.org/p2/${packageName}.json`)
158
- if (!response.ok) {
159
- return null
160
- }
161
- const data = await response.json()
162
- if (data.packages && data.packages[packageName] && data.packages[packageName].length > 0) {
163
- // Get the latest version (first in array is usually latest)
164
- const latest = data.packages[packageName][0]
165
- return latest.version || null
166
- }
167
- return null
168
- } catch (_error) {
169
- return null
170
- }
171
- }
172
-
173
- async function updatePackageJsonDependency(rootDir, packageName, newVersion, field) {
174
- const packageJsonPath = path.join(rootDir, 'package.json')
175
- const raw = await readFile(packageJsonPath, 'utf8')
176
- const pkg = JSON.parse(raw)
177
-
178
- if (!pkg[field]) {
179
- pkg[field] = {}
180
- }
181
-
182
- pkg[field][packageName] = `^${newVersion}`
183
-
184
- const updatedContent = JSON.stringify(pkg, null, 2) + '\n'
185
- await writeFile(packageJsonPath, updatedContent, 'utf8')
186
- }
187
-
188
- async function updateComposerJsonDependency(rootDir, packageName, newVersion, field) {
189
- const composerJsonPath = path.join(rootDir, 'composer.json')
190
- const raw = await readFile(composerJsonPath, 'utf8')
191
- const composer = JSON.parse(raw)
192
-
193
- if (field === 'repositories') {
194
- // Remove the local repository entry
195
- if (composer.repositories && Array.isArray(composer.repositories)) {
196
- composer.repositories = composer.repositories.filter(
197
- (repo) => !(repo.type === 'path' && repo.url === packageName)
198
- )
199
- }
200
- // Update the dependency version in require or require-dev
201
- // We need to find which field contains this package
202
- if (composer.require && composer.require[packageName]) {
203
- composer.require[packageName] = `^${newVersion}`
204
- } else if (composer['require-dev'] && composer['require-dev'][packageName]) {
205
- composer['require-dev'][packageName] = `^${newVersion}`
206
- }
207
- } else {
208
- if (!composer[field]) {
209
- composer[field] = {}
210
- }
211
- composer[field][packageName] = `^${newVersion}`
212
- }
213
-
214
- const updatedContent = JSON.stringify(composer, null, 2) + '\n'
215
- await writeFile(composerJsonPath, updatedContent, 'utf8')
216
- }
217
-
218
- async function runCommand(command, args, { cwd = process.cwd(), capture = false } = {}) {
219
- if (capture) {
220
- const { stdout, stderr } = await runCommandCaptureBase(command, args, { cwd })
221
- return { stdout: stdout.trim(), stderr: stderr.trim() }
222
- }
223
-
224
- await runCommandBase(command, args, { cwd })
225
- return undefined
226
- }
227
-
228
- async function getGitStatus(rootDir) {
229
- try {
230
- const result = await runCommand('git', ['status', '--porcelain'], { capture: true, cwd: rootDir })
231
- return result.stdout || ''
232
- } catch (_error) {
233
- return ''
234
- }
235
- }
236
-
237
- function hasStagedChanges(statusOutput) {
238
- if (!statusOutput || statusOutput.length === 0) {
239
- return false
240
- }
241
-
242
- const lines = statusOutput.split('\n').filter((line) => line.trim().length > 0)
243
-
244
- return lines.some((line) => {
245
- const firstChar = line[0]
246
- return firstChar && firstChar !== ' ' && firstChar !== '?'
247
- })
248
- }
249
-
250
- async function commitDependencyUpdates(rootDir, updatedFiles, promptFn, logFn) {
251
- try {
252
- // Check if we're in a git repository
253
- await runCommand('git', ['rev-parse', '--is-inside-work-tree'], { capture: true, cwd: rootDir })
254
- } catch {
255
- // Not a git repository, skip commit
256
- return false
257
- }
258
-
259
- const fileList = updatedFiles.map((f) => path.basename(f)).join(', ')
260
-
261
- // Stage the updated files
262
- for (const file of updatedFiles) {
263
- try {
264
- await runCommand('git', ['add', file], { cwd: rootDir })
265
- } catch {
266
- // File might not exist or not be tracked, continue
267
- }
268
- }
269
-
270
- // Build commit message
271
- const commitMessage = `chore: update local file dependencies to online versions (${fileList})`
272
-
273
- if (logFn) {
274
- logFn('Committing dependency updates...')
275
- }
276
-
277
- await runCommand('git', ['commit', '-m', commitMessage, '--', ...updatedFiles], { cwd: rootDir })
278
-
279
- if (logFn) {
280
- logFn('Dependency updates committed.')
281
- }
282
-
283
- return true
284
- }
285
-
286
- async function validateLocalDependencies(rootDir, promptFn, logFn = null) {
287
- const packageDeps = await scanPackageJsonDependencies(rootDir)
288
- const composerDeps = await scanComposerJsonDependencies(rootDir)
289
-
290
- const allDeps = [...packageDeps, ...composerDeps]
291
-
292
- if (allDeps.length === 0) {
293
- return
294
- }
295
-
296
- // Fetch latest versions for all dependencies
297
- const depsWithVersions = await Promise.all(
298
- allDeps.map(async (dep) => {
299
- let latestVersion = null
300
- if (dep.field === 'dependencies' || dep.field === 'devDependencies') {
301
- latestVersion = await fetchLatestNpmVersion(dep.packageName)
302
- } else if (dep.field === 'require' || dep.field === 'require-dev') {
303
- latestVersion = await fetchLatestPackagistVersion(dep.packageName)
304
- } else if (dep.field === 'repositories') {
305
- // For repositories, try to extract package name and fetch from Packagist
306
- // The packageName might be the path, so we need to handle this differently
307
- // For now, we'll show the path but can't fetch version
308
- }
309
-
310
- return {
311
- ...dep,
312
- latestVersion
313
- }
314
- })
315
- )
316
-
317
- // Build warning messages with colored output (danger color for package name and version)
318
- const messages = depsWithVersions.map((dep) => {
319
- const packageNameColored = chalk.red(dep.packageName)
320
- const pathColored = chalk.dim(dep.path)
321
- const versionInfo = dep.latestVersion
322
- ? ` Latest version available: ${chalk.red(dep.latestVersion)}.`
323
- : ' Latest version could not be determined.'
324
- return `Dependency ${packageNameColored} is pointing to a local path outside the repository: ${pathColored}.${versionInfo}`
325
- })
326
-
327
- // Build the prompt message with colored count (danger color)
328
- const countColored = chalk.red(allDeps.length)
329
- const countText = allDeps.length === 1 ? 'dependency' : 'dependencies'
330
- const promptMessage = `Found ${countColored} local file ${countText} pointing outside the repository:\n\n${messages.join('\n\n')}\n\nUpdate to latest version?`
331
-
332
- // Prompt user
333
- const { shouldUpdate } = await promptFn([
334
- {
335
- type: 'confirm',
336
- name: 'shouldUpdate',
337
- message: promptMessage,
338
- default: true
339
- }
340
- ])
341
-
342
- if (!shouldUpdate) {
343
- throw new Error('Release cancelled: local file dependencies must be updated before release.')
344
- }
345
-
346
- // If we cannot commit the update, do not proceed (otherwise release-node will fail later with a dirty tree).
347
- // We allow users to opt-in to committing together with existing staged changes via prompt.
348
-
349
- // Track which files were updated
350
- const updatedFiles = new Set()
351
-
352
- // Update dependencies
353
- for (const dep of depsWithVersions) {
354
- if (!dep.latestVersion) {
355
- continue
356
- }
357
-
358
- if (dep.field === 'dependencies' || dep.field === 'devDependencies') {
359
- await updatePackageJsonDependency(rootDir, dep.packageName, dep.latestVersion, dep.field)
360
- updatedFiles.add('package.json')
361
- } else if (dep.field === 'require' || dep.field === 'require-dev') {
362
- await updateComposerJsonDependency(rootDir, dep.packageName, dep.latestVersion, dep.field)
363
- updatedFiles.add('composer.json')
364
- } else if (dep.field === 'repositories') {
365
- // For repositories, we need to remove the repository entry
366
- // But we still need to update the dependency version
367
- // This is more complex, so for now we'll just update if we can find the package
368
- const composerJsonPath = path.join(rootDir, 'composer.json')
369
- const raw = await readFile(composerJsonPath, 'utf8')
370
- const composer = JSON.parse(raw)
371
-
372
- // Try to find which package uses this repository
373
- let packageToUpdate = null
374
- let fieldToUpdate = null
375
-
376
- if (composer.require) {
377
- for (const [pkgName] of Object.entries(composer.require)) {
378
- if (pkgName.includes(dep.packageName.split('/').pop()) || dep.packageName.includes(pkgName.split('/').pop())) {
379
- packageToUpdate = pkgName
380
- fieldToUpdate = 'require'
381
- break
382
- }
383
- }
384
- }
385
-
386
- if (!packageToUpdate && composer['require-dev']) {
387
- for (const [pkgName] of Object.entries(composer['require-dev'])) {
388
- if (pkgName.includes(dep.packageName.split('/').pop()) || dep.packageName.includes(pkgName.split('/').pop())) {
389
- packageToUpdate = pkgName
390
- fieldToUpdate = 'require-dev'
391
- break
392
- }
393
- }
394
- }
395
-
396
- if (packageToUpdate && fieldToUpdate) {
397
- await updateComposerJsonDependency(rootDir, packageToUpdate, dep.latestVersion, fieldToUpdate)
398
- // Also remove the repository entry
399
- const updatedRaw = await readFile(composerJsonPath, 'utf8')
400
- const updatedComposer = JSON.parse(updatedRaw)
401
- if (updatedComposer.repositories && Array.isArray(updatedComposer.repositories)) {
402
- updatedComposer.repositories = updatedComposer.repositories.filter(
403
- (repo) => !(repo.type === 'path' && repo.url === dep.path)
404
- )
405
- const updatedContent = JSON.stringify(updatedComposer, null, 2) + '\n'
406
- await writeFile(composerJsonPath, updatedContent, 'utf8')
407
- }
408
- updatedFiles.add('composer.json')
409
- }
410
- }
411
- }
412
-
413
- // Commit the changes if any files were updated
414
- if (updatedFiles.size > 0) {
415
- const committed = await commitDependencyUpdates(rootDir, Array.from(updatedFiles), promptFn, logFn)
416
- if (!committed) {
417
- throw new Error(
418
- 'Release cancelled: dependency updates were applied but were not committed. Commit/stash your changes and rerun.'
419
- )
420
- }
421
- }
422
- }
423
-
424
- export {
425
- scanPackageJsonDependencies,
426
- scanComposerJsonDependencies,
427
- fetchLatestNpmVersion,
428
- fetchLatestPackagistVersion,
429
- updatePackageJsonDependency,
430
- updateComposerJsonDependency,
431
- isLocalPathOutsideRepo,
432
- validateLocalDependencies
433
- }
1
+ import { readFile, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import process from 'node:process'
4
+ import chalk from 'chalk'
5
+ import { runCommand as runCommandBase, runCommandCapture as runCommandCaptureBase } from './utils/command.mjs'
6
+
7
+ function isLocalPathOutsideRepo(depPath, rootDir) {
8
+ if (!depPath || typeof depPath !== 'string') {
9
+ return false
10
+ }
11
+
12
+ // Remove file: prefix if present
13
+ let cleanPath = depPath
14
+ if (depPath.startsWith('file:')) {
15
+ cleanPath = depPath.slice(5)
16
+ }
17
+
18
+ // Resolve the path relative to the root directory
19
+ const resolvedPath = path.resolve(rootDir, cleanPath)
20
+ const resolvedRoot = path.resolve(rootDir)
21
+
22
+ // Normalize paths to handle different separators
23
+ const normalizedResolved = path.normalize(resolvedPath)
24
+ const normalizedRoot = path.normalize(resolvedRoot)
25
+
26
+ // If paths are equal, it's not outside
27
+ if (normalizedResolved === normalizedRoot) {
28
+ return false
29
+ }
30
+
31
+ // Check if resolved path is outside the repository root
32
+ // Use path.relative to check if the path goes outside
33
+ const relative = path.relative(normalizedRoot, normalizedResolved)
34
+
35
+ // If relative path starts with .., it's outside the repo
36
+ // Also check if the resolved path doesn't start with the root + separator (for absolute paths)
37
+ return relative.startsWith('..') || !normalizedResolved.startsWith(normalizedRoot + path.sep)
38
+ }
39
+
40
+ async function scanPackageJsonDependencies(rootDir) {
41
+ const packageJsonPath = path.join(rootDir, 'package.json')
42
+ const localDeps = []
43
+
44
+ try {
45
+ const raw = await readFile(packageJsonPath, 'utf8')
46
+ const pkg = JSON.parse(raw)
47
+
48
+ const checkDeps = (deps, field) => {
49
+ if (!deps || typeof deps !== 'object') {
50
+ return
51
+ }
52
+
53
+ for (const [packageName, version] of Object.entries(deps)) {
54
+ if (typeof version === 'string' && version.startsWith('file:')) {
55
+ if (isLocalPathOutsideRepo(version, rootDir)) {
56
+ localDeps.push({
57
+ packageName,
58
+ path: version,
59
+ field
60
+ })
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ checkDeps(pkg.dependencies, 'dependencies')
67
+ checkDeps(pkg.devDependencies, 'devDependencies')
68
+
69
+ return localDeps
70
+ } catch (error) {
71
+ if (error.code === 'ENOENT') {
72
+ return []
73
+ }
74
+ throw error
75
+ }
76
+ }
77
+
78
+ async function scanComposerJsonDependencies(rootDir) {
79
+ const composerJsonPath = path.join(rootDir, 'composer.json')
80
+ const localDeps = []
81
+
82
+ try {
83
+ const raw = await readFile(composerJsonPath, 'utf8')
84
+ const composer = JSON.parse(raw)
85
+
86
+ // Check repositories field for local path repositories
87
+ if (composer.repositories && Array.isArray(composer.repositories)) {
88
+ for (const repo of composer.repositories) {
89
+ if (repo.type === 'path' && repo.url) {
90
+ if (isLocalPathOutsideRepo(repo.url, rootDir)) {
91
+ // Try to find which package uses this repository
92
+ // Check require and require-dev for packages that might use this repo
93
+ const repoPath = path.basename(repo.url.replace(/\/$/, ''))
94
+ const possiblePackages = []
95
+
96
+ const checkRequire = (requireObj, field) => {
97
+ if (!requireObj || typeof requireObj !== 'object') {
98
+ return
99
+ }
100
+ for (const [packageName] of Object.entries(requireObj)) {
101
+ // If package name matches the repo path or contains it, it's likely using this repo
102
+ if (packageName.includes(repoPath) || repoPath.includes(packageName.split('/').pop())) {
103
+ possiblePackages.push({ packageName, field })
104
+ }
105
+ }
106
+ }
107
+
108
+ checkRequire(composer.require, 'require')
109
+ checkRequire(composer['require-dev'], 'require-dev')
110
+
111
+ if (possiblePackages.length > 0) {
112
+ for (const { packageName, field } of possiblePackages) {
113
+ localDeps.push({
114
+ packageName,
115
+ path: repo.url,
116
+ field
117
+ })
118
+ }
119
+ } else {
120
+ // If we can't determine which package, still report the repository
121
+ localDeps.push({
122
+ packageName: repo.url,
123
+ path: repo.url,
124
+ field: 'repositories'
125
+ })
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+
132
+ return localDeps
133
+ } catch (error) {
134
+ if (error.code === 'ENOENT') {
135
+ return []
136
+ }
137
+ throw error
138
+ }
139
+ }
140
+
141
+ async function fetchLatestNpmVersion(packageName) {
142
+ try {
143
+ const response = await fetch(`https://registry.npmjs.org/${packageName}/latest`)
144
+ if (!response.ok) {
145
+ return null
146
+ }
147
+ const data = await response.json()
148
+ return data.version || null
149
+ } catch (_error) {
150
+ return null
151
+ }
152
+ }
153
+
154
+ async function fetchLatestPackagistVersion(packageName) {
155
+ try {
156
+ // Packagist API v2 format
157
+ const response = await fetch(`https://repo.packagist.org/p2/${packageName}.json`)
158
+ if (!response.ok) {
159
+ return null
160
+ }
161
+ const data = await response.json()
162
+ if (data.packages && data.packages[packageName] && data.packages[packageName].length > 0) {
163
+ // Get the latest version (first in array is usually latest)
164
+ const latest = data.packages[packageName][0]
165
+ return latest.version || null
166
+ }
167
+ return null
168
+ } catch (_error) {
169
+ return null
170
+ }
171
+ }
172
+
173
+ async function updatePackageJsonDependency(rootDir, packageName, newVersion, field) {
174
+ const packageJsonPath = path.join(rootDir, 'package.json')
175
+ const raw = await readFile(packageJsonPath, 'utf8')
176
+ const pkg = JSON.parse(raw)
177
+
178
+ if (!pkg[field]) {
179
+ pkg[field] = {}
180
+ }
181
+
182
+ pkg[field][packageName] = `^${newVersion}`
183
+
184
+ const updatedContent = JSON.stringify(pkg, null, 2) + '\n'
185
+ await writeFile(packageJsonPath, updatedContent, 'utf8')
186
+ }
187
+
188
+ async function updateComposerJsonDependency(rootDir, packageName, newVersion, field) {
189
+ const composerJsonPath = path.join(rootDir, 'composer.json')
190
+ const raw = await readFile(composerJsonPath, 'utf8')
191
+ const composer = JSON.parse(raw)
192
+
193
+ if (field === 'repositories') {
194
+ // Remove the local repository entry
195
+ if (composer.repositories && Array.isArray(composer.repositories)) {
196
+ composer.repositories = composer.repositories.filter(
197
+ (repo) => !(repo.type === 'path' && repo.url === packageName)
198
+ )
199
+ }
200
+ // Update the dependency version in require or require-dev
201
+ // We need to find which field contains this package
202
+ if (composer.require && composer.require[packageName]) {
203
+ composer.require[packageName] = `^${newVersion}`
204
+ } else if (composer['require-dev'] && composer['require-dev'][packageName]) {
205
+ composer['require-dev'][packageName] = `^${newVersion}`
206
+ }
207
+ } else {
208
+ if (!composer[field]) {
209
+ composer[field] = {}
210
+ }
211
+ composer[field][packageName] = `^${newVersion}`
212
+ }
213
+
214
+ const updatedContent = JSON.stringify(composer, null, 2) + '\n'
215
+ await writeFile(composerJsonPath, updatedContent, 'utf8')
216
+ }
217
+
218
+ async function runCommand(command, args, { cwd = process.cwd(), capture = false } = {}) {
219
+ if (capture) {
220
+ const { stdout, stderr } = await runCommandCaptureBase(command, args, { cwd })
221
+ return { stdout: stdout.trim(), stderr: stderr.trim() }
222
+ }
223
+
224
+ await runCommandBase(command, args, { cwd })
225
+ return undefined
226
+ }
227
+
228
+
229
+ async function commitDependencyUpdates(rootDir, updatedFiles, promptFn, logFn) {
230
+ try {
231
+ // Check if we're in a git repository
232
+ await runCommand('git', ['rev-parse', '--is-inside-work-tree'], { capture: true, cwd: rootDir })
233
+ } catch {
234
+ // Not a git repository, skip commit
235
+ return false
236
+ }
237
+
238
+ const fileList = updatedFiles.map((f) => path.basename(f)).join(', ')
239
+
240
+ // Stage the updated files
241
+ for (const file of updatedFiles) {
242
+ try {
243
+ await runCommand('git', ['add', file], { cwd: rootDir })
244
+ } catch {
245
+ // File might not exist or not be tracked, continue
246
+ }
247
+ }
248
+
249
+ // Build commit message
250
+ const commitMessage = `chore: update local file dependencies to online versions (${fileList})`
251
+
252
+ if (logFn) {
253
+ logFn('Committing dependency updates...')
254
+ }
255
+
256
+ await runCommand('git', ['commit', '-m', commitMessage, '--', ...updatedFiles], { cwd: rootDir })
257
+
258
+ if (logFn) {
259
+ logFn('Dependency updates committed.')
260
+ }
261
+
262
+ return true
263
+ }
264
+
265
+ async function validateLocalDependencies(rootDir, promptFn, logFn = null) {
266
+ const packageDeps = await scanPackageJsonDependencies(rootDir)
267
+ const composerDeps = await scanComposerJsonDependencies(rootDir)
268
+
269
+ const allDeps = [...packageDeps, ...composerDeps]
270
+
271
+ if (allDeps.length === 0) {
272
+ return
273
+ }
274
+
275
+ // Fetch latest versions for all dependencies
276
+ const depsWithVersions = await Promise.all(
277
+ allDeps.map(async (dep) => {
278
+ let latestVersion = null
279
+ if (dep.field === 'dependencies' || dep.field === 'devDependencies') {
280
+ latestVersion = await fetchLatestNpmVersion(dep.packageName)
281
+ } else if (dep.field === 'require' || dep.field === 'require-dev') {
282
+ latestVersion = await fetchLatestPackagistVersion(dep.packageName)
283
+ } else if (dep.field === 'repositories') {
284
+ // For repositories, try to extract package name and fetch from Packagist
285
+ // The packageName might be the path, so we need to handle this differently
286
+ // For now, we'll show the path but can't fetch version
287
+ }
288
+
289
+ return {
290
+ ...dep,
291
+ latestVersion
292
+ }
293
+ })
294
+ )
295
+
296
+ // Build warning messages with colored output (danger color for package name and version)
297
+ const messages = depsWithVersions.map((dep) => {
298
+ const packageNameColored = chalk.red(dep.packageName)
299
+ const pathColored = chalk.dim(dep.path)
300
+ const versionInfo = dep.latestVersion
301
+ ? ` Latest version available: ${chalk.red(dep.latestVersion)}.`
302
+ : ' Latest version could not be determined.'
303
+ return `Dependency ${packageNameColored} is pointing to a local path outside the repository: ${pathColored}.${versionInfo}`
304
+ })
305
+
306
+ // Build the prompt message with colored count (danger color)
307
+ const countColored = chalk.red(allDeps.length)
308
+ const countText = allDeps.length === 1 ? 'dependency' : 'dependencies'
309
+ const promptMessage = `Found ${countColored} local file ${countText} pointing outside the repository:\n\n${messages.join('\n\n')}\n\nUpdate to latest version?`
310
+
311
+ // Prompt user
312
+ const { shouldUpdate } = await promptFn([
313
+ {
314
+ type: 'confirm',
315
+ name: 'shouldUpdate',
316
+ message: promptMessage,
317
+ default: true
318
+ }
319
+ ])
320
+
321
+ if (!shouldUpdate) {
322
+ throw new Error('Release cancelled: local file dependencies must be updated before release.')
323
+ }
324
+
325
+ // If we cannot commit the update, do not proceed (otherwise release-node will fail later with a dirty tree).
326
+ // We allow users to opt-in to committing together with existing staged changes via prompt.
327
+
328
+ // Track which files were updated
329
+ const updatedFiles = new Set()
330
+
331
+ // Update dependencies
332
+ for (const dep of depsWithVersions) {
333
+ if (!dep.latestVersion) {
334
+ continue
335
+ }
336
+
337
+ if (dep.field === 'dependencies' || dep.field === 'devDependencies') {
338
+ await updatePackageJsonDependency(rootDir, dep.packageName, dep.latestVersion, dep.field)
339
+ updatedFiles.add('package.json')
340
+ } else if (dep.field === 'require' || dep.field === 'require-dev') {
341
+ await updateComposerJsonDependency(rootDir, dep.packageName, dep.latestVersion, dep.field)
342
+ updatedFiles.add('composer.json')
343
+ } else if (dep.field === 'repositories') {
344
+ // For repositories, we need to remove the repository entry
345
+ // But we still need to update the dependency version
346
+ // This is more complex, so for now we'll just update if we can find the package
347
+ const composerJsonPath = path.join(rootDir, 'composer.json')
348
+ const raw = await readFile(composerJsonPath, 'utf8')
349
+ const composer = JSON.parse(raw)
350
+
351
+ // Try to find which package uses this repository
352
+ let packageToUpdate = null
353
+ let fieldToUpdate = null
354
+
355
+ if (composer.require) {
356
+ for (const [pkgName] of Object.entries(composer.require)) {
357
+ if (pkgName.includes(dep.packageName.split('/').pop()) || dep.packageName.includes(pkgName.split('/').pop())) {
358
+ packageToUpdate = pkgName
359
+ fieldToUpdate = 'require'
360
+ break
361
+ }
362
+ }
363
+ }
364
+
365
+ if (!packageToUpdate && composer['require-dev']) {
366
+ for (const [pkgName] of Object.entries(composer['require-dev'])) {
367
+ if (pkgName.includes(dep.packageName.split('/').pop()) || dep.packageName.includes(pkgName.split('/').pop())) {
368
+ packageToUpdate = pkgName
369
+ fieldToUpdate = 'require-dev'
370
+ break
371
+ }
372
+ }
373
+ }
374
+
375
+ if (packageToUpdate && fieldToUpdate) {
376
+ await updateComposerJsonDependency(rootDir, packageToUpdate, dep.latestVersion, fieldToUpdate)
377
+ // Also remove the repository entry
378
+ const updatedRaw = await readFile(composerJsonPath, 'utf8')
379
+ const updatedComposer = JSON.parse(updatedRaw)
380
+ if (updatedComposer.repositories && Array.isArray(updatedComposer.repositories)) {
381
+ updatedComposer.repositories = updatedComposer.repositories.filter(
382
+ (repo) => !(repo.type === 'path' && repo.url === dep.path)
383
+ )
384
+ const updatedContent = JSON.stringify(updatedComposer, null, 2) + '\n'
385
+ await writeFile(composerJsonPath, updatedContent, 'utf8')
386
+ }
387
+ updatedFiles.add('composer.json')
388
+ }
389
+ }
390
+ }
391
+
392
+ // Commit the changes if any files were updated
393
+ if (updatedFiles.size > 0) {
394
+ const committed = await commitDependencyUpdates(rootDir, Array.from(updatedFiles), promptFn, logFn)
395
+ if (!committed) {
396
+ throw new Error(
397
+ 'Release cancelled: dependency updates were applied but were not committed. Commit/stash your changes and rerun.'
398
+ )
399
+ }
400
+ }
401
+ }
402
+
403
+ export {
404
+ scanPackageJsonDependencies,
405
+ scanComposerJsonDependencies,
406
+ fetchLatestNpmVersion,
407
+ fetchLatestPackagistVersion,
408
+ updatePackageJsonDependency,
409
+ updateComposerJsonDependency,
410
+ isLocalPathOutsideRepo,
411
+ validateLocalDependencies
412
+ }