Skip to content

Commit c883bed

Browse files
committed
feat: add Dockerfile support
1 parent 4db9e17 commit c883bed

File tree

8 files changed

+587
-59
lines changed

8 files changed

+587
-59
lines changed

.vscode/dictionary.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ davidanson
1515
degit
1616
deps
1717
destructurable
18+
Dockerfiles
1819
dtsx
1920
entrypoints
2021
findstr

bin/cli.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ cli.usage(`[command] [options]
3838
3939
🤖 Buddy Bot - Your companion dependency manager
4040
41-
Supports npm, Bun, yarn, pnpm, Composer, pkgx, Launchpad, and GitHub Actions
41+
Supports npm, Bun, yarn, pnpm, Composer, pkgx, Launchpad, GitHub Actions, and Dockerfiles
4242
Automatically migrates from Renovate and Dependabot
4343
4444
DEPENDENCY MANAGEMENT:
@@ -70,7 +70,7 @@ CONFIGURATION & SETUP:
7070
Examples:
7171
buddy-bot setup # Interactive setup with migration
7272
buddy-bot setup --non-interactive # Automated setup for CI/CD
73-
buddy-bot scan --verbose # Scan for updates (npm + Composer)
73+
buddy-bot scan --verbose # Scan for updates (npm + Composer + Dockerfiles)
7474
buddy-bot rebase 17 # Rebase PR #17
7575
buddy-bot update-check # Auto-rebase checked PRs
7676
buddy-bot cleanup # Clean up stale branches

src/buddy.ts

Lines changed: 138 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@ export class Buddy {
6666
// Get outdated GitHub Actions
6767
const githubActionsUpdates = await this.checkGitHubActionsForUpdates(packageFiles)
6868

69+
// Get outdated Docker images
70+
const dockerUpdates = await this.checkDockerfilesForUpdates(packageFiles)
71+
6972
// Merge all updates
70-
let updates = [...packageJsonUpdates, ...dependencyFileUpdates, ...githubActionsUpdates]
73+
let updates = [...packageJsonUpdates, ...dependencyFileUpdates, ...githubActionsUpdates, ...dockerUpdates]
7174

7275
// Apply ignore filter to dependency file updates
7376
if (this.config.packages?.ignore && this.config.packages.ignore.length > 0) {
@@ -564,6 +567,112 @@ export class Buddy {
564567
return deduplicatedUpdates
565568
}
566569

570+
/**
571+
* Check Dockerfiles for updates
572+
*/
573+
private async checkDockerfilesForUpdates(packageFiles: PackageFile[]): Promise<PackageUpdate[]> {
574+
const { isDockerfile } = await import('./utils/dockerfile-parser')
575+
const { fetchLatestDockerImageVersion } = await import('./utils/dockerfile-parser')
576+
577+
const updates: PackageUpdate[] = []
578+
579+
// Filter to only Dockerfile files
580+
const dockerfiles = packageFiles.filter(file => isDockerfile(file.path))
581+
582+
this.logger.info(`🔍 Found ${dockerfiles.length} Dockerfile(s)`)
583+
584+
for (const file of dockerfiles) {
585+
try {
586+
this.logger.info(`Checking Dockerfile: ${file.path}`)
587+
588+
// Get all Docker image dependencies from this file
589+
const imageDeps = file.dependencies.filter(dep => dep.type === 'docker-image')
590+
this.logger.info(`Found ${imageDeps.length} Docker images in ${file.path}`)
591+
592+
for (const dep of imageDeps) {
593+
try {
594+
this.logger.info(`Checking Docker image: ${dep.name}:${dep.currentVersion}`)
595+
596+
// Check if current version should be respected (like "latest", etc.)
597+
const shouldRespectVersion = (version: string): boolean => {
598+
const respectLatest = this.config.packages?.respectLatest ?? true
599+
if (!respectLatest)
600+
return false
601+
602+
const dynamicIndicators = ['latest', 'main', 'master', 'develop', 'dev', 'stable']
603+
const cleanVersion = version.toLowerCase().trim()
604+
return dynamicIndicators.includes(cleanVersion)
605+
}
606+
607+
if (shouldRespectVersion(dep.currentVersion)) {
608+
this.logger.debug(`Skipping ${dep.name} - version "${dep.currentVersion}" should be respected`)
609+
continue
610+
}
611+
612+
// Fetch latest version for this Docker image
613+
const latestVersion = await fetchLatestDockerImageVersion(dep.name)
614+
615+
if (latestVersion) {
616+
this.logger.info(`Latest version for ${dep.name}: ${latestVersion}`)
617+
618+
if (latestVersion !== dep.currentVersion) {
619+
// Determine update type
620+
const updateType = this.getUpdateType(dep.currentVersion, latestVersion)
621+
622+
this.logger.info(`Update available: ${dep.name} ${dep.currentVersion}${latestVersion} (${updateType})`)
623+
624+
updates.push({
625+
name: dep.name,
626+
currentVersion: dep.currentVersion,
627+
newVersion: latestVersion,
628+
updateType,
629+
dependencyType: 'docker-image',
630+
file: file.path,
631+
metadata: undefined,
632+
releaseNotesUrl: `https://hub.docker.com/r/${dep.name}/tags`,
633+
changelogUrl: undefined,
634+
homepage: `https://hub.docker.com/r/${dep.name}`,
635+
})
636+
}
637+
else {
638+
this.logger.info(`No update needed for ${dep.name}: already at ${latestVersion}`)
639+
}
640+
}
641+
else {
642+
this.logger.warn(`Could not fetch latest version for Docker image ${dep.name}`)
643+
}
644+
}
645+
catch (error) {
646+
this.logger.warn(`Failed to check version for Docker image ${dep.name}:`, error)
647+
}
648+
}
649+
}
650+
catch (error) {
651+
this.logger.error(`Failed to check Dockerfile ${file.path}:`, error)
652+
}
653+
}
654+
655+
this.logger.info(`Generated ${updates.length} Docker image updates`)
656+
657+
// Deduplicate updates by name, version, and file
658+
const deduplicatedUpdates = updates.reduce((acc, update) => {
659+
const existing = acc.find(u =>
660+
u.name === update.name
661+
&& u.currentVersion === update.currentVersion
662+
&& u.newVersion === update.newVersion
663+
&& u.file === update.file,
664+
)
665+
if (!existing) {
666+
acc.push(update)
667+
}
668+
return acc
669+
}, [] as PackageUpdate[])
670+
671+
this.logger.info(`After deduplication: ${deduplicatedUpdates.length} unique Docker image updates`)
672+
673+
return deduplicatedUpdates
674+
}
675+
567676
/**
568677
* Determine update type based on version comparison
569678
*/
@@ -675,11 +784,11 @@ export class Buddy {
675784
// This prevents accidentally updating scripts or other sections with the same package name
676785
const escapedPackageName = cleanPackageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
677786
const escapedSectionName = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
678-
787+
679788
// Match the section, then find the package within that section
680789
const sectionRegex = new RegExp(
681790
`("${escapedSectionName}"\\s*:\\s*\\{[^}]*?)("${escapedPackageName}"\\s*:\\s*")([^"]+)(")([^}]*?\\})`,
682-
'gs'
791+
'gs',
683792
)
684793

685794
// Preserve the original prefix when updating to new version
@@ -708,10 +817,14 @@ export class Buddy {
708817

709818
// Handle dependency file updates (deps.yaml, dependencies.yaml, etc.)
710819
// Only process if we have dependency file updates to avoid unnecessary processing
711-
const dependencyFileUpdates = updates.filter(update =>
712-
(update.file.includes('.yaml') || update.file.includes('.yml'))
713-
&& !update.file.includes('.github/workflows/'),
714-
)
820+
const dependencyFileUpdates = updates.filter((update) => {
821+
const fileName = update.file.toLowerCase()
822+
const isDependencyFile = fileName.endsWith('deps.yaml')
823+
|| fileName.endsWith('deps.yml')
824+
|| fileName.endsWith('dependencies.yaml')
825+
|| fileName.endsWith('dependencies.yml')
826+
return isDependencyFile && !update.file.includes('.github/workflows/')
827+
})
715828
if (dependencyFileUpdates.length > 0) {
716829
try {
717830
const { generateDependencyFileUpdates } = await import('./utils/dependency-file-parser')
@@ -761,6 +874,24 @@ export class Buddy {
761874
}
762875
}
763876

877+
// Handle Dockerfile updates
878+
// Only process if we have Dockerfile updates to avoid unnecessary processing
879+
const dockerfileUpdates = updates.filter(update =>
880+
update.dependencyType === 'docker-image',
881+
)
882+
if (dockerfileUpdates.length > 0) {
883+
try {
884+
const { generateDockerfileUpdates } = await import('./utils/dockerfile-parser')
885+
// Pass only the Dockerfile updates for this specific group
886+
const dockerUpdates = await generateDockerfileUpdates(dockerfileUpdates)
887+
fileUpdates.push(...dockerUpdates)
888+
}
889+
catch (error) {
890+
this.logger.error('Failed to generate Dockerfile updates:', error)
891+
// Continue with other updates even if Dockerfile updates fail
892+
}
893+
}
894+
764895
return fileUpdates
765896
}
766897

src/scanner/package-scanner.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { readdir, readFile, stat } from 'node:fs/promises'
88
import { join } from 'node:path'
99
import { BuddyError } from '../types'
1010
import { isDependencyFile, parseDependencyFile as parseDepFile } from '../utils/dependency-file-parser'
11+
import { isDockerfile, parseDockerfile as parseDockerfileUtil } from '../utils/dockerfile-parser'
1112
import { isGitHubActionsFile, parseGitHubActionsFile } from '../utils/github-actions-parser'
1213

1314
export class PackageScanner {
@@ -95,6 +96,20 @@ export class PackageScanner {
9596
}
9697
}
9798

99+
// Look for Dockerfiles
100+
const dockerfiles = await this.findDockerfiles()
101+
this.logger.info(`🔍 Found ${dockerfiles.length} Dockerfile(s): ${dockerfiles.join(', ')}`)
102+
for (const filePath of dockerfiles) {
103+
if (this.shouldIgnorePath(filePath)) {
104+
continue
105+
}
106+
const packageFile = await this.parseDockerfile(filePath)
107+
if (packageFile) {
108+
packageFiles.push(packageFile)
109+
this.logger.info(`📦 Parsed Dockerfile: ${filePath} with ${packageFile.dependencies.length} dependencies`)
110+
}
111+
}
112+
98113
const duration = Date.now() - startTime
99114
this.logger.success(`Found ${packageFiles.length} package files in ${duration}ms`)
100115

@@ -250,6 +265,45 @@ export class PackageScanner {
250265
return workflowFiles
251266
}
252267

268+
/**
269+
* Find Dockerfiles in the project
270+
*/
271+
private async findDockerfiles(): Promise<string[]> {
272+
const dockerfiles: string[] = []
273+
274+
try {
275+
// Common Dockerfile names
276+
const dockerfileNames = [
277+
'Dockerfile',
278+
'dockerfile',
279+
'Dockerfile.dev',
280+
'Dockerfile.prod',
281+
'Dockerfile.production',
282+
'Dockerfile.development',
283+
'Dockerfile.test',
284+
'Dockerfile.staging',
285+
]
286+
287+
for (const fileName of dockerfileNames) {
288+
const files = await this.findFiles(fileName)
289+
dockerfiles.push(...files)
290+
}
291+
292+
// Also look for files that start with Dockerfile using pattern matching
293+
const dockerfilePatterns = await this.findFilesByPattern('Dockerfile*')
294+
for (const file of dockerfilePatterns) {
295+
if (!dockerfiles.includes(file) && isDockerfile(file)) {
296+
dockerfiles.push(file)
297+
}
298+
}
299+
}
300+
catch {
301+
// Ignore if no Dockerfiles exist
302+
}
303+
304+
return dockerfiles
305+
}
306+
253307
/**
254308
* Parse a GitHub Actions workflow file
255309
*/
@@ -266,6 +320,22 @@ export class PackageScanner {
266320
}
267321
}
268322

323+
/**
324+
* Parse a Dockerfile
325+
*/
326+
async parseDockerfile(filePath: string): Promise<PackageFile | null> {
327+
try {
328+
const fullPath = join(this.projectPath, filePath)
329+
const content = await readFile(fullPath, 'utf-8')
330+
const result = await parseDockerfileUtil(filePath, content)
331+
return result
332+
}
333+
catch (_error) {
334+
this.logger.warn(`Failed to parse Dockerfile ${filePath}:`, _error)
335+
return null
336+
}
337+
}
338+
269339
/**
270340
* Find Composer files in the project
271341
*/

src/types.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ export interface PackageFile {
152152
/** File path relative to repository root */
153153
path: string
154154
/** Type of package file */
155-
type: 'package.json' | 'bun.lockb' | 'package-lock.json' | 'yarn.lock' | 'pnpm-lock.yaml' | 'deps.yaml' | 'deps.yml' | 'dependencies.yaml' | 'dependencies.yml' | 'pkgx.yaml' | 'pkgx.yml' | '.deps.yaml' | '.deps.yml' | 'composer.json' | 'composer.lock' | 'github-actions'
155+
type: 'package.json' | 'bun.lockb' | 'package-lock.json' | 'yarn.lock' | 'pnpm-lock.yaml' | 'deps.yaml' | 'deps.yml' | 'dependencies.yaml' | 'dependencies.yml' | 'pkgx.yaml' | 'pkgx.yml' | '.deps.yaml' | '.deps.yml' | 'composer.json' | 'composer.lock' | 'github-actions' | 'Dockerfile'
156156
/** Raw file content */
157157
content: string
158158
/** Parsed dependencies */
@@ -165,7 +165,7 @@ export interface Dependency {
165165
/** Current version or range */
166166
currentVersion: string
167167
/** Dependency type */
168-
type: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies' | 'require' | 'require-dev' | 'github-actions'
168+
type: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies' | 'require' | 'require-dev' | 'github-actions' | 'docker-image'
169169
/** File where dependency is defined */
170170
file: string
171171
/** Line number in file */
@@ -182,7 +182,7 @@ export interface PackageUpdate {
182182
/** Update type */
183183
updateType: 'major' | 'minor' | 'patch'
184184
/** Dependency type */
185-
dependencyType: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies' | 'require' | 'require-dev' | 'github-actions'
185+
dependencyType: 'dependencies' | 'devDependencies' | 'peerDependencies' | 'optionalDependencies' | 'require' | 'require-dev' | 'github-actions' | 'docker-image'
186186
/** Source file */
187187
file: string
188188
/** Package metadata from registry */

0 commit comments

Comments
 (0)