diff --git a/cmd/mxcli/cmd_bson_dump.go b/cmd/mxcli/cmd_bson_dump.go index 44696b1..7c689fa 100644 --- a/cmd/mxcli/cmd_bson_dump.go +++ b/cmd/mxcli/cmd_bson_dump.go @@ -44,6 +44,9 @@ Examples: # Save dump to file mxcli bson dump -p app.mpr --type page --object "PgTest.MyPage" > mypage.json + + # Extract raw BSON baseline for roundtrip testing + mxcli bson dump -p app.mpr --type page --object "PgTest.MyPage" --format bson > mypage.mxunit `, Run: func(cmd *cobra.Command, args []string) { projectPath, _ := cmd.Flags().GetString("project") @@ -136,6 +139,12 @@ Examples: os.Exit(1) } + if format == "bson" { + // Write raw BSON bytes to stdout (for baseline extraction) + os.Stdout.Write(obj.Contents) + return + } + if format == "ndsl" { var doc bson.D if err := bson.Unmarshal(obj.Contents, &doc); err != nil { @@ -342,5 +351,5 @@ func init() { bsonDumpCmd.Flags().StringP("object", "o", "", "Object qualified name to dump (e.g., Module.PageName)") bsonDumpCmd.Flags().BoolP("list", "l", false, "List all objects of the specified type") bsonDumpCmd.Flags().StringSliceP("compare", "c", nil, "Compare two objects: --compare Obj1,Obj2") - bsonDumpCmd.Flags().String("format", "json", "Output format: json, ndsl") + bsonDumpCmd.Flags().String("format", "json", "Output format: json, ndsl, bson (raw bytes)") } diff --git a/cmd/mxcli/diag.go b/cmd/mxcli/diag.go index 6aae354..27d0c8d 100644 --- a/cmd/mxcli/diag.go +++ b/cmd/mxcli/diag.go @@ -16,6 +16,7 @@ import ( "time" "github.com/mendixlabs/mxcli/mdl/diaglog" + "github.com/mendixlabs/mxcli/sdk/mpr" "github.com/spf13/cobra" ) @@ -47,6 +48,18 @@ Examples: return } + checkUnits, _ := cmd.Flags().GetBool("check-units") + fix, _ := cmd.Flags().GetBool("fix") + if checkUnits { + projectPath, _ := cmd.Flags().GetString("project") + if projectPath == "" { + fmt.Fprintln(os.Stderr, "Error: --check-units requires -p ") + os.Exit(1) + } + runCheckUnits(projectPath, fix) + return + } + if tail > 0 { runDiagTail(logDir, tail) return @@ -60,6 +73,8 @@ func init() { diagCmd.Flags().Bool("log-path", false, "Print log directory path") diagCmd.Flags().Bool("bundle", false, "Create tar.gz with logs for bug reports") diagCmd.Flags().Int("tail", 0, "Show last N log entries") + diagCmd.Flags().Bool("check-units", false, "Check for orphan units and stale mxunit files (MPR v2)") + diagCmd.Flags().Bool("fix", false, "Auto-fix issues found by --check-units") } // runDiagInfo shows diagnostic summary. @@ -252,3 +267,79 @@ func formatBytes(b int64) string { } return fmt.Sprintf("%d KB", b/1024) } + +// runCheckUnits checks for orphan units (Unit table entry without mxunit file) +// and stale mxunit files (file exists but no Unit table entry). MPR v2 only. +func runCheckUnits(mprPath string, fix bool) { + reader, err := mpr.Open(mprPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + defer reader.Close() + + contentsDir := reader.ContentsDir() + if contentsDir == "" { + fmt.Println("Not an MPR v2 project (no mprcontents directory)") + return + } + + // Build set of unit UUIDs from database + unitIDs, err := reader.ListAllUnitIDs() + if err != nil { + fmt.Fprintf(os.Stderr, "Error listing units: %v\n", err) + os.Exit(1) + } + unitSet := make(map[string]bool, len(unitIDs)) + for _, id := range unitIDs { + unitSet[id] = true + } + + // Scan mxunit files + files, err := filepath.Glob(filepath.Join(contentsDir, "*", "*", "*.mxunit")) + if err != nil { + fmt.Fprintf(os.Stderr, "Error scanning mxunit files: %v\n", err) + os.Exit(1) + } + fileSet := make(map[string]string, len(files)) // uuid → filepath + for _, f := range files { + uuid := strings.TrimSuffix(filepath.Base(f), ".mxunit") + fileSet[uuid] = f + } + + // Check for orphan units (in DB but no file) + orphans := 0 + for _, id := range unitIDs { + if _, ok := fileSet[id]; !ok { + fmt.Printf("ORPHAN UNIT: %s (in Unit table but no mxunit file)\n", id) + orphans++ + } + } + + // Check for stale files (file exists but not in DB) + stale := 0 + for uuid, fpath := range fileSet { + if !unitSet[uuid] { + fmt.Printf("STALE FILE: %s\n", uuid) + stale++ + if fix { + if err := os.Remove(fpath); err != nil { + fmt.Fprintf(os.Stderr, " ERROR removing: %v\n", err) + } else { + fmt.Printf(" REMOVED: %s\n", fpath) + // Clean empty parent dirs + dir2 := filepath.Dir(fpath) + os.Remove(dir2) + dir1 := filepath.Dir(dir2) + os.Remove(dir1) + } + } + } + } + + fmt.Printf("\nSummary: %d units in DB, %d mxunit files, %d orphans, %d stale\n", + len(unitIDs), len(files), orphans, stale) + if stale > 0 && !fix { + fmt.Println("Run with --fix to auto-remove stale files") + } +} diff --git a/sdk/mpr/reader.go b/sdk/mpr/reader.go index 8836603..b9e7f9d 100644 --- a/sdk/mpr/reader.go +++ b/sdk/mpr/reader.go @@ -178,6 +178,24 @@ func (r *Reader) ContentsDir() string { return r.contentsDir } +// ListAllUnitIDs returns all unit UUIDs from the Unit table. +func (r *Reader) ListAllUnitIDs() ([]string, error) { + rows, err := r.db.Query("SELECT UnitID FROM Unit") + if err != nil { + return nil, err + } + defer rows.Close() + var ids []string + for rows.Next() { + var unitID []byte + if err := rows.Scan(&unitID); err != nil { + return nil, fmt.Errorf("scanning unit ID: %w", err) + } + ids = append(ids, BlobToUUID(unitID)) + } + return ids, rows.Err() +} + // ProjectVersion returns the Mendix project version information. func (r *Reader) ProjectVersion() *version.ProjectVersion { return r.projectVersion