package main import ( "context" "fmt" "os" "path/filepath" "github.com/spf13/cobra" "git.warky.dev/wdevs/relspecgo/pkg/assetloader" "git.warky.dev/wdevs/relspecgo/pkg/pgsql" ) var ( assetsDir string assetsConn string assetsIgnoreErrors bool ) var assetsCmd = &cobra.Command{ Use: "assets", Short: "Load and execute asset manifests against a database", Long: `Load local binary and text asset files into a PostgreSQL database. Assets are described by YAML manifests (assets.yaml) colocated with the files. Each manifest entry specifies the file to load and the SQL call to execute. File bytes are bound as native pgx parameters — never as SQL text literals — so binary files stay byte-exact with no size or encoding limitations. Manifests must live in directories that follow the naming pattern used by relspec scripts: {priority}_{sequence}_{name}/ or {priority}-{sequence}-{name}/ This allows asset-loading steps to be ordered correctly alongside SQL scripts in a migrate-apply pipeline. Manifest format (assets.yaml): - file: invoice.md call: | INSERT INTO org.filepointer (rid_owner, filename, contenttype, jsonstore) VALUES (1, :filename, 'text/markdown', jsonb_build_object('content', :bytes::text)) - file: logo.png call: UPDATE branding SET logo = :bytes WHERE id = 1 params: owner_id: "42" Built-in placeholders: :bytes — the file's raw content as bytea :filename — the base name of the file (string) :any_key — a static value declared in the entry's params map`, } var assetsListCmd = &cobra.Command{ Use: "list", Short: "List asset manifests from a directory", Long: `List all asset manifest entries from a directory in execution order. The directory is scanned recursively for assets.yaml files located in directories that follow the {priority}_{sequence}_{name} naming convention. Example: relspec assets list --dir ./sql`, RunE: runAssetsList, } var assetsExecuteCmd = &cobra.Command{ Use: "execute", Short: "Execute asset manifests against a database", Long: `Execute asset manifest entries from a directory against a PostgreSQL database. Asset manifests are executed in order: Priority (ascending), Sequence (ascending), Directory name (alphabetical). By default, execution stops on the first error. Use --ignore-errors to continue even when individual entries fail. PostgreSQL Connection String Examples: postgres://username:password@localhost:5432/database_name postgresql://user:pass@host/dbname?sslmode=disable Examples: relspec assets execute --dir ./sql \ --conn "postgres://user:pass@localhost:5432/mydb" relspec assets execute --dir ./sql \ --conn "postgres://localhost/mydb" \ --ignore-errors`, RunE: runAssetsExecute, } func init() { assetsListCmd.Flags().StringVar(&assetsDir, "dir", "", "Directory to scan for asset manifests (required)") if err := assetsListCmd.MarkFlagRequired("dir"); err != nil { fmt.Fprintf(os.Stderr, "Error marking dir flag as required: %v\n", err) } assetsExecuteCmd.Flags().StringVar(&assetsDir, "dir", "", "Directory to scan for asset manifests (required)") assetsExecuteCmd.Flags().StringVar(&assetsConn, "conn", "", "PostgreSQL connection string (required)") assetsExecuteCmd.Flags().BoolVar(&assetsIgnoreErrors, "ignore-errors", false, "Continue executing even if entries fail") if err := assetsExecuteCmd.MarkFlagRequired("dir"); err != nil { fmt.Fprintf(os.Stderr, "Error marking dir flag as required: %v\n", err) } if err := assetsExecuteCmd.MarkFlagRequired("conn"); err != nil { fmt.Fprintf(os.Stderr, "Error marking conn flag as required: %v\n", err) } assetsCmd.AddCommand(assetsListCmd) assetsCmd.AddCommand(assetsExecuteCmd) } func runAssetsList(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "\n=== Asset Manifests List ===\n") fmt.Fprintf(os.Stderr, "Directory: %s\n\n", assetsDir) items, err := assetloader.ScanDir(assetsDir) if err != nil { return fmt.Errorf("scanning directory: %w", err) } if len(items) == 0 { fmt.Fprintf(os.Stderr, "No asset manifests found.\n\n") return nil } fmt.Fprintf(os.Stderr, "Found %d asset entry(ies) in execution order:\n\n", len(items)) fmt.Fprintf(os.Stderr, "%-4s %-10s %-8s %-20s %s\n", "No.", "Priority", "Sequence", "Dir", "File") fmt.Fprintf(os.Stderr, "%-4s %-10s %-8s %-20s %s\n", "----", "--------", "--------", "--------------------", "----") for i, item := range items { fmt.Fprintf(os.Stderr, "%-4d %-10d %-8d %-20s %s\n", i+1, item.Priority, item.Sequence, item.DirName, filepath.Base(item.Entry.File), ) } fmt.Fprintf(os.Stderr, "\n") return nil } func runAssetsExecute(cmd *cobra.Command, args []string) error { fmt.Fprintf(os.Stderr, "\n=== Asset Manifests Execution ===\n") fmt.Fprintf(os.Stderr, "Started at: %s\n", getCurrentTimestamp()) fmt.Fprintf(os.Stderr, "Directory: %s\n", assetsDir) fmt.Fprintf(os.Stderr, "Database: %s\n\n", maskPassword(assetsConn)) fmt.Fprintf(os.Stderr, "[1/2] Scanning asset manifests...\n") items, err := assetloader.ScanDir(assetsDir) if err != nil { return fmt.Errorf("scanning directory: %w", err) } if len(items) == 0 { fmt.Fprintf(os.Stderr, " No asset manifests found. Nothing to execute.\n\n") return nil } fmt.Fprintf(os.Stderr, " ✓ Found %d asset entry(ies)\n\n", len(items)) fmt.Fprintf(os.Stderr, "[2/2] Executing assets in order (Priority → Sequence → Dir)...\n\n") ctx := context.Background() conn, err := pgsql.Connect(ctx, assetsConn, "assets-execute") if err != nil { return fmt.Errorf("connecting to database: %w", err) } defer conn.Close(ctx) successCount := 0 var failures []struct { item assetloader.Item err error } for _, item := range items { name := filepath.Base(item.Entry.File) fmt.Printf("Executing asset: %s (Priority=%d, Sequence=%d, Dir=%s)\n", name, item.Priority, item.Sequence, item.DirName) if err := assetloader.ExecuteItem(ctx, conn, item); err != nil { if assetsIgnoreErrors { fmt.Printf("⚠ Error loading %s: %v (continuing due to --ignore-errors)\n", name, err) failures = append(failures, struct { item assetloader.Item err error }{item, err}) continue } return fmt.Errorf("asset %s (Priority=%d, Sequence=%d): %w", name, item.Priority, item.Sequence, err) } successCount++ fmt.Printf("✓ Successfully loaded: %s\n", name) } fmt.Fprintf(os.Stderr, "\n=== Execution Complete ===\n") fmt.Fprintf(os.Stderr, "Completed at: %s\n", getCurrentTimestamp()) fmt.Fprintf(os.Stderr, "Total entries: %d\n", len(items)) fmt.Fprintf(os.Stderr, "Successful: %d\n", successCount) if len(failures) > 0 { fmt.Fprintf(os.Stderr, "Failed: %d\n", len(failures)) fmt.Fprintf(os.Stderr, "\n⚠ Failed Entries Summary (%d failed):\n", len(failures)) for i, f := range failures { fmt.Fprintf(os.Stderr, " %d. %s (Priority=%d, Sequence=%d)\n Error: %v\n", i+1, filepath.Base(f.item.Entry.File), f.item.Priority, f.item.Sequence, f.err) } } fmt.Fprintf(os.Stderr, "\n") return nil }