buddy-bot
Version:
The Stacks CLI.
1,313 lines (1,122 loc) ⢠70 kB
text/typescript
#!/usr/bin/env bun
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 { checkForAutoClose, extractPackageNamesFromPRBody } from '../src/utils/helpers'
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, and GitHub Actions
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
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)
buddy-bot dashboard --pin # Create pinned dashboard
buddy-bot rebase 17 # Rebase PR #17
buddy-bot update-check # Auto-rebase checked PRs
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 --pin')
.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
// eslint-disable-next-line no-cond-assign
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 {
logger.info('š Checking for PRs with rebase checkbox enabled...')
// 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,
)
// Get all open PRs
const prs = await gitProvider.getPullRequests('open')
const buddyPRs = prs.filter(pr =>
pr.head.startsWith('buddy-bot/')
|| pr.author === 'github-actions[bot]'
|| pr.author.includes('buddy'),
)
if (buddyPRs.length === 0) {
logger.info('š No buddy-bot PRs found')
return
}
logger.info(`š Found ${buddyPRs.length} buddy-bot PR(s)`)
let rebasedCount = 0
let closedCount = 0
for (const pr of buddyPRs) {
// First, check if this PR should be auto-closed due to respectLatest config changes
const shouldAutoClose = await checkForAutoClose(pr, config, logger)
if (shouldAutoClose) {
if (options.dryRun) {
logger.info(`š [DRY RUN] Would auto-close PR #${pr.number} (contains dynamic versions that are now filtered)`)
// Extract package names for logging
const packageNames = extractPackageNamesFromPRBody(pr.body)
logger.info(`š PR contains packages: ${packageNames.join(', ')}`)
closedCount++
continue
}
else {
logger.info(`š Auto-closing PR #${pr.number} (contains dynamic versions that are now filtered by respectLatest config)`)
// Extract package names for logging
const packageNames = extractPackageNamesFromPRBody(pr.body)
logger.info(`š PR contains packages: ${packageNames.join(', ')}`)
try {
await gitProvider.closePullRequest(pr.number)
logger.success(`ā
Successfully closed PR #${pr.number} (contains dynamic versions that are now filtered by respectLatest configuration)`)
closedCount++
continue
}
catch (error) {
logger.error(`ā Failed to close PR #${pr.number}:`, error)
}
}
}
// Check if rebase checkbox is checked
const isRebaseChecked = checkRebaseCheckbox(pr.body)
if (isRebaseChecked) {
logger.info(`š PR #${pr.number} has rebase checkbox checked: ${pr.title}`)
if (options.dryRun) {
logger.info('š [DRY RUN] Would rebase this PR')
rebasedCount++
}
else {
logger.info(`š Rebasing PR #${pr.number}...`)
try {
// Extract package updates from PR body
const packageUpdates = await extractPackageUpdatesFromPRBody(pr.body)
if (packageUpdates.length === 0) {
logger.warn(`ā ļø Could not extract package updates from PR #${pr.number}, skipping`)
continue
}
// Update the existing PR with latest updates (true rebase)
const buddy = new Buddy({
...config,
verbose: options.verbose ?? config.verbose,
})
const scanResult = await buddy.scanForUpdates()
if (scanResult.updates.length === 0) {
logger.info('ā
All dependencies are now up to date!')
continue
}
// 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.warn(`ā ļø Could not find matching update group for PR #${pr.number}. 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(`š” Skipping rebase - close this PR manually and let buddy-bot create new ones with correct grouping`)
continue
}
// 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(pr.number, {
title: group.title,
body: updatedBody,
})
logger.success(`š Successfully rebased PR #${pr.number} in place!`)
rebasedCount++
}
catch (error) {
logger.error(`ā Failed to rebase PR #${pr.number}:`, error)
}
}
}
else {
logger.info(`š PR #${pr.number}: No rebase requested`)
}
}
if (closedCount > 0) {
logger.success(`š ${options.dryRun ? 'Would close' : 'Successfully closed'} ${closedCount} PR(s) with dynamic versions`)
}
if (rebasedCount > 0) {
logger.success(`ā
${options.dryRun ? 'Would rebase' : 'Successfully rebased'} ${rebasedCount} PR(s)`)
}
if (closedCount === 0 && rebasedCount === 0) {
logger.info('ā
No PRs need attention')
}
}
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(), log