buddy-bot
Version:
Automated & optimized dependency updates for JavaScript & TypeScript projects. Like Renovate & Dependabot.
1,322 lines (1,132 loc) โข 78.3 kB
text/typescript
#!/usr/bin/env bun
/* eslint-disable no-cond-assign */
import type { BuddyBotConfig } from '../src/types'
import fs from 'node:fs'
import process from 'node:process'
import { CAC } from 'cac'
import prompts from 'prompts'
import { version } from '../package.json'
import { Buddy } from '../src/buddy'
import { config } from '../src/config'
import {
analyzeProject,
ConfigurationMigrator,
confirmTokenSetup,
createProgressTracker,
detectRepository,
displayProgress,
displayValidationResults,
generateConfigFile,
generateCoreWorkflows,
getWorkflowPreset,
guideRepositorySettings,
guideTokenCreation,
PluginManager,
runPreflightChecks,
setupCustomWorkflow,
showFinalInstructions,
updateProgress,
validateRepositoryAccess,
validateWorkflowGeneration,
} from '../src/setup'
import { Logger } from '../src/utils/logger'
const cli = new CAC('buddy-bot')
cli.usage(`[command] [options]
๐ค Buddy Bot - Your companion dependency manager
Supports npm, Bun, yarn, pnpm, Composer, pkgx, Launchpad, GitHub Actions, and Dockerfiles
Automatically migrates from Renovate and Dependabot
DEPENDENCY MANAGEMENT:
setup ๐ Interactive setup for automated updates (recommended)
scan ๐ Scan for dependency updates
dashboard ๐ Create or update dependency dashboard issue
update โฌ๏ธ Update dependencies and create PRs
rebase ๐ Rebase/retry a pull request with latest updates
update-check ๐ Auto-detect and rebase PRs with checked rebase box
check ๐ Check specific packages for updates
schedule โฐ Run automated updates on schedule
PACKAGE INFORMATION:
info ๐ฆ Show detailed package information
versions ๐ Show all available versions of a package
latest โญ Get the latest version of a package
exists โ
Check if a package exists in the registry
deps ๐ Show package dependencies
compare โ๏ธ Compare two versions of a package
search ๐ Search for packages in the registry
BRANCH MANAGEMENT:
cleanup ๐งน Clean up stale buddy-bot branches
list-branches ๐ List all buddy-bot branches and their status
CONFIGURATION & SETUP:
open-settings ๐ง Open GitHub repository and organization settings pages
Examples:
buddy-bot setup # Interactive setup with migration
buddy-bot setup --non-interactive # Automated setup for CI/CD
buddy-bot scan --verbose # Scan for updates (npm + Composer + Dockerfiles)
buddy-bot rebase 17 # Rebase PR #17
buddy-bot update-check # Auto-rebase checked PRs
buddy-bot cleanup # Clean up stale branches
buddy-bot list-branches # List all buddy-bot branches
buddy-bot info laravel/framework # Get Composer package info
buddy-bot info react # Get npm package info
buddy-bot versions react --latest 5 # Show recent versions
buddy-bot search "test framework" # Search packages
buddy-bot open-settings # Open GitHub settings
Migration:
- Automatically detects Renovate and Dependabot configurations
- Converts settings to Buddy Bot format with compatibility report
- Generates optimized GitHub Actions workflows
- Provides migration guidance and best practices`)
// Define CLI options interface to match our core types
interface CLIOptions {
verbose?: boolean
config?: string
packages?: string
pattern?: string
strategy?: 'major' | 'minor' | 'patch' | 'all'
ignore?: string
dryRun?: boolean
respectLatest?: boolean
}
cli
.command('setup', '๐ Interactive setup for automated dependency updates (recommended)')
.option('--verbose, -v', 'Enable verbose logging')
.option('--non-interactive', 'Run setup without prompts (use defaults)')
.option('--preset <type>', 'Workflow preset: standard|high-frequency|security|minimal|testing', { default: 'standard' })
.option('--token-setup <type>', 'Token setup: existing-secret|new-pat|default-token', { default: 'default-token' })
.example('buddy-bot setup')
.example('buddy-bot setup --verbose')
.example('buddy-bot setup --non-interactive')
.example('buddy-bot setup --non-interactive --preset testing --verbose')
.action(async (options: CLIOptions & { nonInteractive?: boolean, preset?: string, tokenSetup?: string }) => {
const logger = options.verbose ? Logger.verbose() : Logger.quiet()
try {
console.log('๐ค Welcome to Buddy Bot Setup!')
console.log('Let\'s configure automated dependency updates for your project.\n')
// Initialize progress tracking
const progress = createProgressTracker(10) // Updated total steps including new features
displayProgress(progress)
// Configuration Migration Detection
updateProgress(progress, 'Detecting existing configurations')
displayProgress(progress)
const migrator = new ConfigurationMigrator()
const existingTools = await migrator.detectExistingTools()
const migrationResults: any[] = []
if (existingTools.length > 0 && !options.nonInteractive) {
console.log(`\n๐ Configuration Migration Detection:`)
console.log(`Found ${existingTools.length} existing dependency management tool(s):`)
existingTools.forEach(tool => console.log(` โข ${tool.name} (${tool.configFile})`))
const migrateResponse = await prompts({
type: 'confirm',
name: 'migrate',
message: 'Would you like to migrate existing configurations to Buddy Bot?',
initial: true,
})
if (migrateResponse.migrate) {
console.log('\n๐ Migrating configurations...')
for (const tool of existingTools) {
try {
let result
if (tool.name === 'renovate') {
result = await migrator.migrateFromRenovate(tool.configFile)
}
else if (tool.name === 'dependabot') {
result = await migrator.migrateFromDependabot(tool.configFile)
}
else {
continue
}
migrationResults.push(result)
console.log(`โ
Migrated ${tool.name} configuration`)
}
catch (error) {
console.log(`โ ๏ธ Failed to migrate ${tool.name}: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
}
if (migrationResults.length > 0) {
const report = await migrator.generateMigrationReport(migrationResults)
console.log(`\n${report}`)
}
}
}
else if (existingTools.length > 0 && options.nonInteractive) {
console.log(`\n๐ Found ${existingTools.length} existing tool(s), skipping migration in non-interactive mode`)
}
// Plugin Discovery
updateProgress(progress, 'Discovering integrations', true)
displayProgress(progress)
const pluginManager = new PluginManager()
const availablePlugins = await pluginManager.discoverPlugins()
if (availablePlugins.length > 0 && !options.nonInteractive) {
console.log(`\n๐ Integration Discovery:`)
console.log(`Found ${availablePlugins.length} available integration(s):`)
availablePlugins.forEach(plugin => console.log(` โข ${plugin.name} v${plugin.version}`))
const pluginResponse = await prompts({
type: 'confirm',
name: 'enablePlugins',
message: 'Would you like to enable these integrations?',
initial: true,
})
if (pluginResponse.enablePlugins) {
for (const plugin of availablePlugins) {
await pluginManager.loadPlugin(plugin)
}
}
}
else if (availablePlugins.length > 0 && options.nonInteractive) {
console.log(`\n๐ Found ${availablePlugins.length} integration(s), skipping in non-interactive mode`)
}
// Pre-flight checks
updateProgress(progress, 'Running pre-flight checks', true)
displayProgress(progress)
const preflightResults = await runPreflightChecks()
displayValidationResults(preflightResults, '๐ Pre-flight Validation')
if (!preflightResults.success) {
console.log('\nโ Pre-flight checks failed. Please fix the errors above and try again.')
process.exit(1)
}
// Project analysis
updateProgress(progress, 'Analyzing project', true)
displayProgress(progress)
const projectAnalysis = await analyzeProject()
console.log(`\n๐ Project Analysis:`)
console.log(`๐ฆ Project Type: ${projectAnalysis.type}`)
console.log(`โ๏ธ Package Manager: ${projectAnalysis.packageManager}`)
console.log(`๐ Lock File: ${projectAnalysis.hasLockFile ? 'Found' : 'Not found'}`)
console.log(`๐ Dependency Files: ${projectAnalysis.hasDependencyFiles ? 'Found' : 'None'}`)
console.log(`๐ GitHub Actions: ${projectAnalysis.hasGitHubActions ? 'Found' : 'None'}`)
console.log(`๐ก Recommended Preset: ${projectAnalysis.recommendedPreset}`)
if (projectAnalysis.recommendations.length > 0) {
console.log('\n๐ Recommendations:')
projectAnalysis.recommendations.forEach((rec: string) => console.log(` โข ${rec}`))
}
// Step 1: Repository Detection
updateProgress(progress, 'Repository Detection', true)
displayProgress(progress)
console.log('\n๐ Repository Detection')
const repoInfo = await detectRepository()
if (!repoInfo) {
console.log('โ Could not detect repository. Please ensure you\'re in a Git repository.')
process.exit(1)
}
console.log(`โ
Detected repository: ${repoInfo.owner}/${repoInfo.name}`)
console.log(`๐ GitHub URL: https://github.com/${repoInfo.owner}/${repoInfo.name}`)
// Validate repository access
const repoValidation = await validateRepositoryAccess(repoInfo)
if (repoValidation.warnings.length > 0 || repoValidation.suggestions.length > 0) {
displayValidationResults(repoValidation, '๐ Repository Validation')
}
// Step 2: Token Setup Guide
updateProgress(progress, 'GitHub Token Setup', true)
displayProgress(progress)
console.log('\n๐ GitHub Token Setup')
console.log('For full functionality, Buddy Bot needs appropriate GitHub permissions.')
console.log('This enables workflow file updates and advanced GitHub Actions features.\n')
let tokenSetup
if (options.nonInteractive) {
// Use default token setup based on flag
switch (options.tokenSetup) {
case 'existing-secret':
tokenSetup = { hasCustomToken: true, needsGuide: false }
console.log('โ
Using existing organization/repository secrets')
break
case 'new-pat':
tokenSetup = { hasCustomToken: true, needsGuide: true }
console.log('โ ๏ธ Non-interactive mode: Will use custom token but skip setup guide')
break
case 'default-token':
default:
tokenSetup = { hasCustomToken: false, needsGuide: false }
console.log('โ
Using default GITHUB_TOKEN (limited functionality)')
break
}
}
else {
tokenSetup = await confirmTokenSetup()
if (tokenSetup.needsGuide) {
await guideTokenCreation(repoInfo)
}
}
// Step 3: Repository Settings
updateProgress(progress, 'Repository Settings', true)
displayProgress(progress)
console.log('\n๐ง Repository Settings')
await guideRepositorySettings(repoInfo)
// Step 4: Workflow Configuration
updateProgress(progress, 'Workflow Configuration', true)
displayProgress(progress)
console.log('\nโ๏ธ Workflow Configuration')
let workflowResponse
if (options.nonInteractive) {
workflowResponse = { useCase: options.preset }
console.log(`โ
Using ${options.preset} preset for workflow configuration`)
}
else {
workflowResponse = await prompts([
{
type: 'select',
name: 'useCase',
message: 'What type of update schedule would you like?',
choices: [
{
title: 'Standard Setup (Recommended)',
description: 'Dashboard updates 3x/week, dependency updates on schedule',
value: 'standard',
},
{
title: 'High Frequency',
description: 'Check for updates multiple times per day',
value: 'high-frequency',
},
{
title: 'Security Focused',
description: 'Frequent patch updates with security-first approach',
value: 'security',
},
{
title: 'Minimal Updates',
description: 'Weekly checks, lower frequency',
value: 'minimal',
},
{
title: 'Development/Testing',
description: 'Manual triggers + frequent checks for testing',
value: 'testing',
},
{
title: 'Custom Configuration',
description: 'Create your own schedule',
value: 'custom',
},
],
},
])
if (!workflowResponse.useCase) {
console.log('Setup cancelled.')
return
}
}
// Step 5: Generate Configuration File
updateProgress(progress, 'Configuration File', true)
displayProgress(progress)
console.log('\n๐ Configuration File')
await generateConfigFile(repoInfo, tokenSetup.hasCustomToken)
// Step 6: Generate Workflows
updateProgress(progress, 'Workflow Generation', true)
displayProgress(progress)
console.log('\n๐ Workflow Generation')
const preset = getWorkflowPreset(workflowResponse.useCase)
if (workflowResponse.useCase === 'custom' && !options.nonInteractive) {
await setupCustomWorkflow(preset, logger)
}
else {
console.log(`โจ Setting up ${preset.name}...`)
console.log(`๐ ${preset.description}`)
}
// Generate the core workflows based on the provided templates
await generateCoreWorkflows(preset, repoInfo, tokenSetup.hasCustomToken, logger)
// Step 7: Workflow Validation
updateProgress(progress, 'Workflow Validation', true)
displayProgress(progress)
console.log('\n๐ Validating Generated Workflows')
// Validate each generated workflow
const workflowFiles = [
{ name: 'buddy-dashboard.yml', content: '' },
{ name: 'buddy-check.yml', content: '' },
{ name: 'buddy-update.yml', content: '' },
]
let validationPassed = true
for (const workflowFile of workflowFiles) {
try {
const workflowPath = `.github/workflows/${workflowFile.name}`
if (fs.existsSync(workflowPath)) {
const content = fs.readFileSync(workflowPath, 'utf8')
const validation = await validateWorkflowGeneration(content)
if (!validation.success) {
console.log(`โ ${workflowFile.name} validation failed`)
displayValidationResults(validation, `${workflowFile.name} Issues`)
validationPassed = false
}
else {
console.log(`โ
${workflowFile.name} validated successfully`)
}
}
}
catch {
console.log(`โ ๏ธ Could not validate ${workflowFile.name}`)
}
}
if (!validationPassed) {
console.log('\nโ ๏ธ Some workflows have validation issues. Please review the warnings above.')
}
// Step 8: Final Setup Instructions & Plugin Execution
updateProgress(progress, 'Setup Complete', true)
displayProgress(progress)
console.log('\n๐ Setup Complete!')
await showFinalInstructions(repoInfo, tokenSetup.hasCustomToken)
// Execute plugin hooks for setup completion
if (availablePlugins.length > 0) {
console.log('\n๐ Executing integration hooks...')
const setupContext = {
step: 'setup_complete',
progress,
config: migrationResults.length > 0 ? migrationResults[0].migratedSettings : {},
repository: repoInfo,
analysis: projectAnalysis,
plugins: availablePlugins,
}
pluginManager.setContext(setupContext)
await pluginManager.executePluginHooks({ event: 'setup_complete' })
}
}
catch (error) {
logger.error('Setup failed:', error)
process.exit(1)
}
})
cli
.command('scan', 'Scan for dependency updates')
.option('--verbose, -v', 'Enable verbose logging')
.option('--packages <names>', 'Comma-separated list of packages to check')
.option('--pattern <pattern>', 'Glob pattern to match packages')
.option('--strategy <type>', 'Update strategy: major|minor|patch|all', { default: 'all' })
.option('--ignore <names>', 'Comma-separated list of packages to ignore')
.option('--respect-latest', 'Respect "latest", "*", and other dynamic version indicators (default: true)')
.option('--no-respect-latest', 'Allow updating "latest", "*", and other dynamic version indicators')
.example('buddy-bot scan')
.example('buddy-bot scan --verbose')
.example('buddy-bot scan --packages "react,typescript,laravel/framework"')
.example('buddy-bot scan --pattern "@types/*"')
.example('buddy-bot scan --strategy minor')
.example('buddy-bot scan --no-respect-latest')
.action(async (options: CLIOptions) => {
const logger = options.verbose ? Logger.verbose() : Logger.quiet()
try {
logger.info('Loading configuration...')
// Parse packages from string if provided
let packages: string[] | undefined
if (options.packages) {
packages = options.packages.split(',').map(p => p.trim())
}
// Parse ignore list from string if provided
let ignore: string[] | undefined
if (options.ignore) {
ignore = options.ignore.split(',').map(p => p.trim())
}
// Override config with CLI options
const finalConfig: BuddyBotConfig = {
...config,
verbose: options.verbose ?? config.verbose,
packages: {
...config.packages,
strategy: options.strategy ?? config.packages?.strategy ?? 'all',
ignore: ignore ?? config.packages?.ignore,
respectLatest: options.respectLatest ?? config.packages?.respectLatest ?? true,
},
}
const buddy = new Buddy(finalConfig)
if (packages?.length) {
logger.info(`Checking specific packages: ${packages.join(', ')}`)
const updates = await buddy.checkPackages(packages)
if (updates.length === 0) {
logger.success('All specified packages are up to date!')
}
else {
logger.info(`Found ${updates.length} updates:`)
for (const update of updates) {
logger.info(` ${update.name}: ${update.currentVersion} โ ${update.newVersion} (${update.updateType})`)
}
}
return
}
if (options.pattern) {
logger.info(`Checking packages with pattern: ${options.pattern}`)
const updates = await buddy.checkPackagesWithPattern(options.pattern)
if (updates.length === 0) {
logger.success('All matching packages are up to date!')
}
else {
logger.info(`Found ${updates.length} updates:`)
for (const update of updates) {
logger.info(` ${update.name}: ${update.currentVersion} โ ${update.newVersion} (${update.updateType})`)
}
}
return
}
// Full project scan
const scanResult = await buddy.scanForUpdates()
if (scanResult.updates.length === 0) {
logger.success('All dependencies are up to date!')
return
}
logger.info(`\nScan Results:`)
logger.info(`๐ฆ Total packages: ${scanResult.totalPackages}`)
logger.info(`๐ Available updates: ${scanResult.updates.length}`)
logger.info(`โฑ๏ธ Scan duration: ${scanResult.duration}ms`)
// Group updates by type
const majorUpdates = scanResult.updates.filter(u => u.updateType === 'major')
const minorUpdates = scanResult.updates.filter(u => u.updateType === 'minor')
const patchUpdates = scanResult.updates.filter(u => u.updateType === 'patch')
if (majorUpdates.length > 0) {
logger.warn(`\n๐จ Major updates (${majorUpdates.length}):`)
for (const update of majorUpdates) {
logger.info(` ${update.name}: ${update.currentVersion} โ ${update.newVersion}`)
}
}
if (minorUpdates.length > 0) {
logger.info(`\nโจ Minor updates (${minorUpdates.length}):`)
for (const update of minorUpdates) {
logger.info(` ${update.name}: ${update.currentVersion} โ ${update.newVersion}`)
}
}
if (patchUpdates.length > 0) {
logger.info(`\n๐ง Patch updates (${patchUpdates.length}):`)
for (const update of patchUpdates) {
logger.info(` ${update.name}: ${update.currentVersion} โ ${update.newVersion}`)
}
}
if (scanResult.groups.length > 0) {
logger.info(`\n๐ Update groups (${scanResult.groups.length}):`)
for (const group of scanResult.groups) {
logger.info(` ${group.name}: ${group.updates.length} updates`)
}
}
}
catch (error) {
logger.error('Scan failed:', error)
process.exit(1)
}
})
cli
.command('dashboard', 'Create or update dependency dashboard issue')
.option('--verbose, -v', 'Enable verbose logging')
.option('--title <title>', 'Custom dashboard title')
.option('--issue-number <number>', 'Update specific issue number')
.example('buddy-bot dashboard')
.example('buddy-bot dashboard --title "My Dependencies"')
.example('buddy-bot dashboard --issue-number 42')
.action(async (options: CLIOptions & { pin?: boolean, title?: string, issueNumber?: string }) => {
const logger = options.verbose ? Logger.verbose() : Logger.quiet()
try {
logger.info('Creating or updating dependency dashboard...')
// Check if repository is configured
if (!config.repository) {
logger.error('โ Repository configuration required for dashboard')
logger.info('Configure repository.provider, repository.owner, repository.name in buddy-bot.config.ts')
process.exit(1)
}
// Override config with CLI options
const finalConfig: BuddyBotConfig = {
...config,
verbose: options.verbose ?? config.verbose,
dashboard: {
...config.dashboard,
enabled: true,
title: options.title ?? config.dashboard?.title,
issueNumber: options.issueNumber ? Number.parseInt(options.issueNumber) : config.dashboard?.issueNumber,
},
}
const buddy = new Buddy(finalConfig)
const issue = await buddy.createOrUpdateDashboard()
logger.success(`โ
Dashboard updated: ${issue.url}`)
logger.info(`๐ Issue #${issue.number}: ${issue.title}`)
}
catch (error) {
logger.error('Dashboard creation failed:', error)
process.exit(1)
}
})
cli
.command('update', 'Update dependencies and create PRs')
.option('--verbose, -v', 'Enable verbose logging')
.option('--strategy <type>', 'Update strategy: major|minor|patch|all', { default: 'all' })
.option('--ignore <names>', 'Comma-separated list of packages to ignore')
.option('--dry-run', 'Preview changes without making them')
.option('--respect-latest', 'Respect "latest", "*", and other dynamic version indicators (default: true)')
.option('--no-respect-latest', 'Allow updating "latest", "*", and other dynamic version indicators')
.example('buddy-bot update')
.example('buddy-bot update --dry-run')
.example('buddy-bot update --strategy patch')
.example('buddy-bot update --verbose')
.example('buddy-bot update --no-respect-latest')
.action(async (options: CLIOptions) => {
const logger = options.verbose ? Logger.verbose() : Logger.quiet()
try {
logger.info('Starting dependency update process...')
// Parse ignore list from string if provided
let ignore: string[] | undefined
if (options.ignore) {
ignore = options.ignore.split(',').map(p => p.trim())
}
const finalConfig: BuddyBotConfig = {
...config,
verbose: options.verbose ?? config.verbose,
packages: {
...config.packages,
strategy: options.strategy ?? config.packages?.strategy ?? 'all',
ignore: ignore ?? config.packages?.ignore,
respectLatest: options.respectLatest ?? config.packages?.respectLatest ?? true,
},
}
const buddy = new Buddy(finalConfig)
const scanResult = await buddy.scanForUpdates()
if (scanResult.updates.length === 0) {
logger.success('All dependencies are up to date!')
return
}
if (options.dryRun) {
logger.info('๐ Dry run mode - no changes will be made')
logger.info(`Would create ${scanResult.groups.length} pull request(s):`)
for (const group of scanResult.groups) {
logger.info(` ๐ ${group.title} (${group.updates.length} updates)`)
}
return
}
// Create pull requests
await buddy.createPullRequests(scanResult)
logger.success('Update process completed!')
}
catch (error) {
logger.error('Update failed:', error)
process.exit(1)
}
})
cli
.command('rebase <pr-number>', 'Rebase/retry a pull request by recreating it with latest updates')
.option('--verbose, -v', 'Enable verbose logging')
.option('--force', 'Force rebase even if PR appears to be up to date')
.example('buddy-bot rebase 17')
.example('buddy-bot rebase 17 --verbose')
.example('buddy-bot rebase 17 --force')
.action(async (prNumber: string, options: CLIOptions & { force?: boolean }) => {
const logger = options.verbose ? Logger.verbose() : Logger.quiet()
try {
logger.info(`๐ Rebasing/retrying PR #${prNumber}...`)
// Check if repository is configured
if (!config.repository) {
logger.error('โ Repository configuration required for PR operations')
logger.info('Configure repository.provider, repository.owner, repository.name in buddy-bot.config.ts')
process.exit(1)
}
// Get GitHub token from environment (prefer BUDDY_BOT_TOKEN for full permissions)
const token = process.env.BUDDY_BOT_TOKEN || process.env.GITHUB_TOKEN
if (!token) {
logger.error('โ GITHUB_TOKEN or BUDDY_BOT_TOKEN environment variable required for PR operations')
process.exit(1)
}
const { GitHubProvider } = await import('../src/git/github-provider')
const hasWorkflowPermissions = !!process.env.BUDDY_BOT_TOKEN
const gitProvider = new GitHubProvider(
token,
config.repository.owner,
config.repository.name,
hasWorkflowPermissions,
)
const prNum = Number.parseInt(prNumber)
if (Number.isNaN(prNum)) {
logger.error('โ Invalid PR number provided')
process.exit(1)
}
// Get the PR to rebase
const prs = await gitProvider.getPullRequests('open')
const pr = prs.find(p => p.number === prNum)
if (!pr) {
logger.error(`โ Could not find open PR #${prNum}`)
process.exit(1)
}
if (!pr.head.startsWith('buddy-bot/')) {
logger.error(`โ PR #${prNum} is not a buddy-bot PR (branch: ${pr.head})`)
process.exit(1)
}
logger.info(`๐ Found PR: ${pr.title}`)
logger.info(`๐ฟ Branch: ${pr.head}`)
// Extract package updates from PR body to determine what to update
const packageUpdates = await extractPackageUpdatesFromPRBody(pr.body)
if (packageUpdates.length === 0) {
logger.error('โ Could not extract package updates from PR body')
process.exit(1)
}
logger.info(`๐ฆ Found ${packageUpdates.length} packages to update`)
// Check if we need to rebase by scanning for current updates
if (!options.force) {
logger.info('๐ Checking if rebase is needed...')
const buddy = new Buddy(config)
const scanResult = await buddy.scanForUpdates()
// Check if current scan matches PR content
const currentUpdates = scanResult.updates.filter(u =>
packageUpdates.some(pu => pu.name === u.name),
)
const upToDate = packageUpdates.every(pu =>
currentUpdates.some(cu =>
cu.name === pu.name
&& cu.newVersion === pu.newVersion,
),
)
if (upToDate) {
logger.success('โ
PR is already up to date, no rebase needed')
logger.info('๐ก Use --force to rebase anyway')
return
}
}
// Update the existing PR with latest updates (true rebase)
logger.info('๐ Updating PR with latest updates...')
// Get latest updates
const buddy = new Buddy({
...config,
verbose: options.verbose ?? config.verbose,
})
const scanResult = await buddy.scanForUpdates()
if (scanResult.updates.length === 0) {
logger.success('โ
All dependencies are now up to date!')
return
}
// Find the matching update group - must match exactly
const group = scanResult.groups.find(g =>
g.updates.length === packageUpdates.length
&& g.updates.every(u => packageUpdates.some(pu => pu.name === u.name))
&& packageUpdates.every(pu => g.updates.some(u => u.name === pu.name)),
)
if (!group) {
logger.error('โ Could not find matching update group. This likely means the package grouping has changed.')
logger.info(`๐ PR packages: ${packageUpdates.map(p => p.name).join(', ')}`)
logger.info(`๐ Available groups: ${scanResult.groups.map(g => `${g.name} (${g.updates.length} packages)`).join(', ')}`)
logger.info(`๐ก Close this PR manually and let buddy-bot create new ones with correct grouping`)
return
}
// Generate new file changes (package.json, dependency files, GitHub Actions)
const packageJsonUpdates = await buddy.generateAllFileUpdates(group.updates)
// Update the branch with new commits
await gitProvider.commitChanges(pr.head, group.title, packageJsonUpdates)
logger.info(`โ
Updated branch ${pr.head} with latest changes`)
// Generate new PR content
const { PullRequestGenerator } = await import('../src/pr/pr-generator')
const prGenerator = new PullRequestGenerator({ verbose: options.verbose })
const newBody = await prGenerator.generateBody(group)
// Update the PR with new title/body (and uncheck the rebase box)
const updatedBody = newBody.replace(
/- \[x\] <!-- rebase-check -->/g,
'- [ ] <!-- rebase-check -->',
)
await gitProvider.updatePullRequest(prNum, {
title: group.title,
body: updatedBody,
})
logger.success('๐ PR rebase completed! Updated existing PR in place.')
}
catch (error) {
logger.error('Rebase failed:', error)
process.exit(1)
}
})
// Helper function to extract package updates from PR body
async function extractPackageUpdatesFromPRBody(body: string): Promise<Array<{ name: string, currentVersion: string, newVersion: string }>> {
const updates: Array<{ name: string, currentVersion: string, newVersion: string }> = []
// Match table rows with package updates - handles both npm and Composer formats
// npm format: | [package] | [`version` -> `version`] |
// Composer format: | [package](link) | `version` -> `version` | file | status |
const tableRowRegex = /\|\s*\[([^\]]+)\][^|]*\|\s*\[?`\^?([^`]+)`\s*->\s*`\^?([^`]+)`\]?/g
let match
while ((match = tableRowRegex.exec(body)) !== null) {
const [, packageName, currentVersion, newVersion] = match
updates.push({
name: packageName,
currentVersion,
newVersion,
})
}
return updates
}
cli
.command('update-check', 'Check all open buddy-bot PRs for rebase checkbox and auto-rebase if checked')
.option('--verbose, -v', 'Enable verbose logging')
.option('--dry-run', 'Check but don\'t actually rebase')
.example('buddy-bot update-check')
.example('buddy-bot update-check --verbose')
.example('buddy-bot update-check --dry-run')
.action(async (options: CLIOptions) => {
const logger = options.verbose ? Logger.verbose() : Logger.quiet()
try {
// Check if repository is configured
if (!config.repository) {
logger.error('โ Repository configuration required for PR operations')
logger.info('Configure repository.provider, repository.owner, repository.name in buddy-bot.config.ts')
process.exit(1)
}
// Get GitHub token from environment (prefer BUDDY_BOT_TOKEN for full permissions)
const token = process.env.BUDDY_BOT_TOKEN || process.env.GITHUB_TOKEN
if (!token) {
logger.error('โ GITHUB_TOKEN or BUDDY_BOT_TOKEN environment variable required for PR operations')
process.exit(1)
}
const { GitHubProvider } = await import('../src/git/github-provider')
const hasWorkflowPermissions = !!process.env.BUDDY_BOT_TOKEN
const gitProvider = new GitHubProvider(
token,
config.repository.owner,
config.repository.name,
hasWorkflowPermissions,
)
// Step 1: Check for rebase checkboxes using HTTP (no API auth needed)
logger.info('๐ Checking for PRs with rebase checkbox enabled...')
let rebasedCount = 0
let checkedPRs = 0
try {
// Get PR numbers from git refs
const prRefsOutput = await gitProvider.runCommand('git', ['ls-remote', 'origin', 'refs/pull/*/head'])
const prNumbers: number[] = []
for (const line of prRefsOutput.split('\n')) {
if (line.trim()) {
const parts = line.trim().split('\t')
if (parts.length === 2) {
const ref = parts[1] // refs/pull/123/head
const prMatch = ref.match(/refs\/pull\/(\d+)\/head/)
if (prMatch) {
prNumbers.push(Number.parseInt(prMatch[1]))
}
}
}
}
logger.info(`๐ Found ${prNumbers.length} PRs to check for rebase requests`)
// Check each PR for rebase checkbox (in small batches)
const batchSize = 3 // Smaller batches for PR content fetching
for (let i = 0; i < prNumbers.length && i < 20; i += batchSize) { // Limit to first 20 PRs
const batch = prNumbers.slice(i, i + batchSize)
for (const prNumber of batch) {
try {
// Fetch PR page HTML to check for rebase checkbox
const url = `https://github.com/${config.repository.owner}/${config.repository.name}/pull/${prNumber}`
const response = await fetch(url, {
headers: { 'User-Agent': 'buddy-bot/1.0' },
})
if (response.ok) {
const html = await response.text()
checkedPRs++
// Check if PR is open and has rebase checkbox checked
const isOpen = html.includes('State--open') && !html.includes('State--closed') && !html.includes('State--merged')
const hasRebaseChecked = checkRebaseCheckbox(html)
if (isOpen && hasRebaseChecked) {
logger.info(`๐ PR #${prNumber} has rebase checkbox checked`)
if (options.dryRun) {
logger.info(`๐ [DRY RUN] Would rebase PR #${prNumber}`)
rebasedCount++
}
else {
logger.info(`๐ Rebasing PR #${prNumber} via rebase command...`)
try {
// Use the existing rebase command logic
const { spawn } = await import('node:child_process')
const rebaseProcess = spawn('bunx', ['buddy-bot', 'rebase', prNumber.toString()], {
stdio: 'inherit',
cwd: process.cwd(),
})
await new Promise((resolve, reject) => {
rebaseProcess.on('close', (code) => {
if (code === 0) {
rebasedCount++
resolve(code)
}
else {
reject(new Error(`Rebase failed with code ${code}`))
}
})
})
logger.success(`โ
Successfully rebased PR #${prNumber}`)
}
catch (rebaseError) {
logger.error(`โ Failed to rebase PR #${prNumber}:`, rebaseError)
}
}
}
}
// Small delay between requests
await new Promise(resolve => setTimeout(resolve, 200))
}
catch (error) {
logger.warn(`โ ๏ธ Could not check PR #${prNumber}:`, error)
}
}
// Delay between batches
if (i + batchSize < Math.min(prNumbers.length, 20)) {
await new Promise(resolve => setTimeout(resolve, 1000))
}
}
if (rebasedCount > 0) {
logger.success(`โ
${options.dryRun ? 'Would rebase' : 'Successfully rebased'} ${rebasedCount} PR(s)`)
}
else if (checkedPRs > 0) {
logger.info('๐ No PRs have rebase checkbox enabled')
}
}
catch (error) {
logger.warn('โ ๏ธ Could not check for rebase requests:', error)
}
// Step 2: Check for obsolete PRs (composer files removed, etc.)
logger.info('\n๐ Checking for obsolete PRs due to removed dependency files...')
try {
const { Buddy } = await import('../src/buddy')
const buddy = new Buddy(config)
await buddy.checkAndCloseObsoletePRs(gitProvider, !!options.dryRun)
}
catch (error) {
logger.warn('โ ๏ธ Could not check for obsolete PRs:', error)
}
// Step 3: Run branch cleanup (uses local git commands, no API calls)
logger.info('\n๐งน Running branch cleanup...')
const result = await gitProvider.cleanupStaleBranches(2, !!options.dryRun)
if (options.dryRun) {
logger.info(`๐ [DRY RUN] Would delete ${result.deleted.length} stale branches`)
}
else {
logger.success(`๐ Cleanup complete: ${result.deleted.length} branches deleted, ${result.failed.length} failed`)
}
// Summary
if (rebasedCount > 0 || result.deleted.length > 0) {
logger.success(`\n๐ Update-check complete: ${rebasedCount} PR(s) rebased, ${result.deleted.length} branches cleaned`)
}
}
catch (error) {
logger.error('update-check failed:', error)
process.exit(1)
}
})
// Helper function to check if rebase checkbox is checked
function checkRebaseCheckbox(body: string): boolean {
// Look for the checked rebase checkbox pattern - handle both "rebase/retry" and "update/retry"
const checkedPattern = /- \[x\] <!-- rebase-check -->If you want to (?:rebase|update)\/retry this PR, check this box/i
return checkedPattern.test(body)
}
cli
.command('check <packages...>', 'Check specific packages for updates')
.option('--verbose, -v', 'Enable verbose logging')
.option('--strategy <type>', 'Update strategy: major|minor|patch|all', { default: 'all' })
.example('buddy-bot check react typescript')
.example('buddy-bot check react --verbose')
.action(async (...args: any[]) => {
// CAC passes individual arguments, then options as the last parameter
const options: CLIOptions = args[args.length - 1]
const packages: string[] = args.slice(0, -1)
const checkLogger = options.verbose ? Logger.verbose() : Logger.quiet()
if (!packages.length) {
checkLogger.error('No packages specified to check')
process.exit(1)
}
try {
checkLogger.info(`Checking specific packages: ${packages.join(', ')}`)
const finalConfig: BuddyBotConfig = {
...config,
verbose: options.verbose ?? config.verbose,
packages: {
...config.packages,
strategy: options.strategy ?? config.packages?.strategy ?? 'all',
},
}
const buddy = new Buddy(finalConfig)
const updates = await buddy.checkPackages(packages)
if (updates.length === 0) {
checkLogger.success('All specified packages are up to date!')
}
else {
checkLogger.info(`Found ${updates.length} updates:`)
for (const update of updates) {
checkLogger.info(` ${update.name}: ${update.currentVersion} โ ${update.newVersion} (${update.updateType})`)
}
}
}
catch (error) {
checkLogger.error('Check failed:', error)
process.exit(1)
}
})
cli
.command('schedule', 'Run automated dependency updates on schedule')
.option('--verbose, -v', 'Enable verbose logging')
.option('--strategy <type>', 'Update strategy: major|minor|patch|all', { default: 'all' })
.example('buddy-bot schedule')
.example('buddy-bot schedule --verbose')
.example('buddy-bot schedule --strategy patch')
.action(async (options: CLIOptions) => {
const { Scheduler } = await import('../src/scheduler/scheduler')
const logger = options.verbose ? Logger.verbose() : Logger.quiet()
try {
logger.info('๐ Starting Buddy Scheduler...')
// Parse ignore list from string if provided
let ignore: string[] | undefined
if (options.ignore) {
ignore = options.ignore.split(',').map(p => p.trim())
}
const finalConfig: BuddyBotConfig = {
...config,
verbose: options.verbose ?? config.verbose,
packages: {
...config.packages,
strategy: options.strategy ?? config.packages?.strategy ?? 'all',
ignore: ignore ?? config.packages?.ignore,
},
}
// Validate that repository is configured for scheduling
if (!finalConfig.repository?.provider || !finalConfig.repository?.owner || !finalConfig.repository?.name) {
logger.error('โ Repository configuration required for scheduling. Please configure:')
logger.info(' - repository.provider (github, gitlab, etc.)')
logger.info(' - repository.owner')
logger.info(' - repository.name')
logger.info(' - repository.token (via environment variable)')
process.exit(1)
}
const scheduler = new Scheduler(options.verbose)
const job = Scheduler.createJobFromConfig(finalConfig, 'cli-schedule')
// Override cron if provided
if (options.strategy) {
switch (options.strategy) {
case 'major':
job.schedule.cron = Scheduler.PRESETS.WEEKLY
break
case 'minor':
job.schedule.cron = Scheduler.PRESETS.TWICE_WEEKLY
break
case 'patch':
job.schedule.cron = Scheduler.PRESETS.DAILY
break
default:
job.schedule.cron = Scheduler.PRESETS.WEEKLY
}
}
scheduler.addJob(job)
scheduler.start()
logger.success(`โ
Scheduler started with cron: ${job.schedule.cron}`)
logger.info('๐
Next run:', job.nextRun?.toISOString() || 'Unknown')
logger.info('๐ Press Ctrl+C to stop the scheduler')
// Keep process alive
process.stdin.resume()
}
catch (error) {
logger.error('Scheduler failed:', error)
process.exit(1)
}
})
cli
.command('generate-workflows', 'Generate GitHub Actions workflow templates (deprecated - use "setup" instead)')
.option('--verbose, -v', 'Enable verbose logging')
.example('buddy-bot generate-workflows')
.example('buddy-bot generate-workflows --verbose')
.action(async (options: CLIOptions) => {
const { GitHubActionsTemplate } = await import('../src/templates/github-actions')
const { writeFileSync, mkdirSync } = await import('node:fs')
const { resolve } = await import('node:path')
const logger = options.verbose ? Logger.verbose() : Logger.quiet()
console.log('โ ๏ธ The "generate-workflows" command is deprecated.')
console.log('๐ก Use "buddy-bot setup" for a better interactive experience.\n')
try {
const finalConfig: BuddyBotConfig = {
...config,
verbose: options.verbose ?? config.verbose,
}
const outputDir = finalConfig.workflows?.outputDir
? resolve(process.cwd(), finalConfig.workflows.outputDir)
: resolve(process.cwd(), '.github', 'workflows')
logger.info('๐ Generating GitHub Actions workflow templates...')
// Create output directory
try {
mkdirSync(outputDir, { recursive: true })
}
catch {
// Directory already exists
}
// Generate workflows based on configuration
const templates = finalConfig.workflows?.templates || {
comprehensive: true,
daily: true,
weekly: true,
monthly: true,
docker: true,
monorepo: true,
}
let generatedCount = 0
// Generate standard scheduled workflows
if (templates.daily || templates.weekly || templates.monthly) {
const workflows = GitHubActionsTemplate.generateScheduledWorkflows(finalConfig)
for (const [filename, content] of Object.entries(workflows)) {
const shouldGenerate = (
(filename.includes('daily') && templates.daily)
|| (filename.includes('weekly') && templates.weekly)
|| (filename.includes('monthly') && templates.monthly)
)
if (shouldGenerate) {
const filepath = resolve(outputDir, filename)
writeFileSync(filepath, content)
logger.success(`Generated: ${filename}`)
generatedCount++
}
}
}
// Generate comprehensive workflow
if (templates.comprehensive) {
const comprehensiveWorkflow = GitHubActionsTemplate.generateComprehensiveWorkflow(finalConfig)
writeFileSync(resolve(outputDir, 'buddy-comprehensive.yml'), comprehensiveWorkflow)
logger.success('Generated: buddy-comprehensive.yml')
generatedCount++
}
// Generate specialized workflows
if (templates.docker) {
const dockerWorkflow = GitHubActionsTemplate.generateDockerWorkflow(finalConfig)
writeFileSync(resolve(outputDir, 'buddy-docker.yml'), dockerWorkflow)
logger.success('Generated: buddy-docker.yml')
generatedCount++
}
if (templates.monorepo) {
const monorepoWorkflow = GitHubActionsTemplate.generateMonorepoWorkflow(finalConfig)
writeFileSync(resolve(outputDir, 'buddy-monorepo.yml'), monorepoWorkflow)
logger.success('Generated: buddy-monorepo.yml')
generatedCount++
}
// Generate custom workflows
if (finalConfig.workflows?.custom?.length) {
for (const customWorkflow of finalConfig.workflows.custom) {
const workflowContent = GitHubActionsTemplate.generateCustomWorkflow(customWorkflow, finalConfig)
const filename = `buddy-${customWorkflow.name.toLowerCase().replace(/\s+/g, '-')}.yml`
writeFileSync(resolve(outputDir, filename), workflowContent)
logger.success(`Generated: ${filename}`)
generatedCount++
}
}
if (generatedCount === 0) {
logger.warn('No workflows were generated. Check your configuration in buddy-bot.config.ts')
logger.info('Set workflows.templates to enable specific templates or add custom workflows.')
return
}
logger.success(`\n๐ ${generatedCount} GitHub Actions workflow(s) generated!`)
logger.info(`๐ Location: ${outputDir}`)
logger.info('\n๐ก Next steps:')
logger.info(' 1. Review and customize the workflows for your project')
logger.info(' 2. Ensure GITHUB_TOKEN is available as a secret')
logger.info(' 3. Configure buddy-bot.config.ts with your repository settings')
logger.info(' 4. Enable GitHub Actions in your repository settings')
logger.info('\n๐ Learn more: https://docs.github.com/en/actions')
}
catch (error) {
logger.error('Failed to generate workflows:', error)
process.exit(1)
}
})
cli
.command('info <package>', 'Show detailed package information')
.option('--verbose, -v', 'Enable verbose logging')
.option('--json', 'Output in JSON format')
.example('buddy-bot info react')
.example('buddy-bot info react --json')
.example('buddy-bot info typescript@latest')
.action(async (packageName: string, options: CLIOptions & { json?: boolean }) => {
const { RegistryClient } = await import('../src/registry/registry-client')
const logger = options.verbose ? Logger.verbose() : Logger.quiet()
try {
const registryClient = new RegistryClient(process.cwd(), logger, undefined)
if (options.json) {
// Output raw JSON from bun info
const { spawn } = await import('node:child_process')
const child = spawn('bun', ['info', packageName, '--json'], {
stdio: 'inherit',
})
child.on('close', (code) => {
process.exit(code || 0)
})
return
}
// Get package metadata and display in a nice format
const metadata = await