package main import ( "context" "database/sql" "fmt" "os" "path/filepath" "strings" "github.com/fatih/color" "github.com/spf13/cobra" "github.com/steveyegge/beads" "github.com/steveyegge/beads/internal/storage/sqlite" _ "modernc.org/sqlite" ) var migrateCmd = &cobra.Command{ Use: "migrate", Short: "Migrate database to current version", Long: `Detect and migrate database files to the current version. This command: - Finds all .db files in .beads/ - Checks schema versions - Migrates old databases to beads.db - Updates schema version metadata - Removes stale databases (with confirmation)`, Run: func(cmd *cobra.Command, _ []string) { autoYes, _ := cmd.Flags().GetBool("yes") cleanup, _ := cmd.Flags().GetBool("cleanup") dryRun, _ := cmd.Flags().GetBool("dry-run") // Find .beads directory beadsDir := findBeadsDir() if beadsDir == "" { if jsonOutput { outputJSON(map[string]interface{}{ "error": "no_beads_directory", "message": "No .beads directory found. Run 'bd init' first.", }) } else { fmt.Fprintf(os.Stderr, "Error: no .beads directory found\n") fmt.Fprintf(os.Stderr, "Hint: run 'bd init' to initialize bd\n") } os.Exit(1) } // Detect all database files databases, err := detectDatabases(beadsDir) if err != nil { if jsonOutput { outputJSON(map[string]interface{}{ "error": "detection_failed", "message": err.Error(), }) } else { fmt.Fprintf(os.Stderr, "Error: %v\n", err) } os.Exit(1) } if len(databases) == 0 { if jsonOutput { outputJSON(map[string]interface{}{ "status": "no_databases", "message": "No database files found in .beads/", }) } else { fmt.Fprintf(os.Stderr, "No database files found in %s\n", beadsDir) fmt.Fprintf(os.Stderr, "Run 'bd init' to create a new database.\n") } return } // Check if beads.db exists and is current targetPath := filepath.Join(beadsDir, beads.CanonicalDatabaseName) var currentDB *dbInfo var oldDBs []*dbInfo for _, db := range databases { if db.path == targetPath { currentDB = db } else { oldDBs = append(oldDBs, db) } } // Print status if !jsonOutput { fmt.Printf("Database migration status:\n\n") if currentDB != nil { fmt.Printf(" Current database: %s\n", filepath.Base(currentDB.path)) fmt.Printf(" Schema version: %s\n", currentDB.version) if currentDB.version != Version { color.Yellow(" ⚠ Version mismatch (current: %s, expected: %s)\n", currentDB.version, Version) } else { color.Green(" ✓ Version matches\n") } } else { color.Yellow(" No beads.db found\n") } if len(oldDBs) > 0 { fmt.Printf("\n Old databases found:\n") for _, db := range oldDBs { fmt.Printf(" - %s (version: %s)\n", filepath.Base(db.path), db.version) } } fmt.Println() } // Determine migration actions needsMigration := false needsVersionUpdate := false if currentDB == nil && len(oldDBs) == 1 { // Migrate single old database to beads.db needsMigration = true } else if currentDB == nil && len(oldDBs) > 1 { // Multiple old databases - ambiguous if jsonOutput { outputJSON(map[string]interface{}{ "error": "ambiguous_migration", "message": "Multiple old database files found", "databases": formatDBList(oldDBs), }) } else { fmt.Fprintf(os.Stderr, "Error: multiple old database files found:\n") for _, db := range oldDBs { fmt.Fprintf(os.Stderr, " - %s (version: %s)\n", filepath.Base(db.path), db.version) } fmt.Fprintf(os.Stderr, "\nPlease manually rename the correct database to beads.db and remove others.\n") } os.Exit(1) } else if currentDB != nil && currentDB.version != Version { // Update version metadata needsVersionUpdate = true } // Perform migrations if dryRun { if jsonOutput { outputJSON(map[string]interface{}{ "dry_run": true, "needs_migration": needsMigration, "needs_version_update": needsVersionUpdate, "old_databases": formatDBList(oldDBs), }) } else { fmt.Println("Dry run mode - no changes will be made") if needsMigration { fmt.Printf("Would migrate: %s → beads.db\n", filepath.Base(oldDBs[0].path)) } if needsVersionUpdate { fmt.Printf("Would update version: %s → %s\n", currentDB.version, Version) } if cleanup && len(oldDBs) > 0 { fmt.Printf("Would remove %d old database(s)\n", len(oldDBs)) } } return } // Migrate old database to beads.db if needsMigration { oldDB := oldDBs[0] if !jsonOutput { fmt.Printf("Migrating database: %s → beads.db\n", filepath.Base(oldDB.path)) } if err := os.Rename(oldDB.path, targetPath); err != nil { if jsonOutput { outputJSON(map[string]interface{}{ "error": "migration_failed", "message": err.Error(), }) } else { fmt.Fprintf(os.Stderr, "Error: failed to migrate database: %v\n", err) } os.Exit(1) } // Update current DB reference currentDB = oldDB currentDB.path = targetPath needsVersionUpdate = true if !jsonOutput { color.Green("✓ Migration complete\n\n") } } // Update schema version if needed if needsVersionUpdate && currentDB != nil { if !jsonOutput { fmt.Printf("Updating schema version: %s → %s\n", currentDB.version, Version) } store, err := sqlite.New(currentDB.path) if err != nil { if jsonOutput { outputJSON(map[string]interface{}{ "error": "version_update_failed", "message": err.Error(), }) } else { fmt.Fprintf(os.Stderr, "Error: failed to open database: %v\n", err) } os.Exit(1) } ctx := context.Background() if err := store.SetMetadata(ctx, "bd_version", Version); err != nil { _ = store.Close() if jsonOutput { outputJSON(map[string]interface{}{ "error": "version_update_failed", "message": err.Error(), }) } else { fmt.Fprintf(os.Stderr, "Error: failed to update version: %v\n", err) } os.Exit(1) } _ = store.Close() if !jsonOutput { color.Green("✓ Version updated\n\n") } } // Clean up old databases if cleanup && len(oldDBs) > 0 { // If we migrated one database, remove it from the cleanup list if needsMigration { oldDBs = oldDBs[1:] } if len(oldDBs) > 0 { if !autoYes && !jsonOutput { fmt.Printf("Found %d old database file(s):\n", len(oldDBs)) for _, db := range oldDBs { fmt.Printf(" - %s (version: %s)\n", filepath.Base(db.path), db.version) } fmt.Print("\nRemove these files? [y/N] ") var response string fmt.Scanln(&response) if strings.ToLower(response) != "y" && strings.ToLower(response) != "yes" { fmt.Println("Cleanup cancelled") return } } for _, db := range oldDBs { if err := os.Remove(db.path); err != nil { if !jsonOutput { color.Yellow("Warning: failed to remove %s: %v\n", filepath.Base(db.path), err) } } else if !jsonOutput { fmt.Printf("Removed %s\n", filepath.Base(db.path)) } } if !jsonOutput { color.Green("\n✓ Cleanup complete\n") } } } // Final status if jsonOutput { outputJSON(map[string]interface{}{ "status": "success", "current_database": beads.CanonicalDatabaseName, "version": Version, "migrated": needsMigration, "version_updated": needsVersionUpdate, "cleaned_up": cleanup && len(oldDBs) > 0, }) } else { fmt.Println("\nMigration complete!") fmt.Printf("Current database: beads.db (version %s)\n", Version) } }, } type dbInfo struct { path string version string } func detectDatabases(beadsDir string) ([]*dbInfo, error) { pattern := filepath.Join(beadsDir, "*.db") matches, err := filepath.Glob(pattern) if err != nil { return nil, fmt.Errorf("failed to search for databases: %w", err) } var databases []*dbInfo for _, match := range matches { // Skip backup files if strings.HasSuffix(match, ".backup.db") { continue } // Check if file exists and is readable info, err := os.Stat(match) if err != nil || info.IsDir() { continue } // Get version from database version := getDBVersion(match) databases = append(databases, &dbInfo{ path: match, version: version, }) } return databases, nil } func getDBVersion(dbPath string) string { // Open database read-only db, err := sql.Open("sqlite", dbPath+"?mode=ro") if err != nil { return "unknown" } defer db.Close() // Try to read version from metadata table var version string err = db.QueryRow("SELECT value FROM metadata WHERE key = 'bd_version'").Scan(&version) if err == nil { return version } // Check if metadata table exists var tableName string err = db.QueryRow(` SELECT name FROM sqlite_master WHERE type='table' AND name='metadata' `).Scan(&tableName) if err == sql.ErrNoRows { return "pre-0.17.5" } return "unknown" } func formatDBList(dbs []*dbInfo) []map[string]string { result := make([]map[string]string, len(dbs)) for i, db := range dbs { result[i] = map[string]string{ "path": db.path, "name": filepath.Base(db.path), "version": db.version, } } return result } func init() { migrateCmd.Flags().Bool("yes", false, "Auto-confirm cleanup prompts") migrateCmd.Flags().Bool("cleanup", false, "Remove old database files after migration") migrateCmd.Flags().Bool("dry-run", false, "Show what would be done without making changes") rootCmd.AddCommand(migrateCmd) }