diff --git a/cmd/bbrew/main.go b/cmd/bbrew/main.go index e4d9ab4..4e6188e 100644 --- a/cmd/bbrew/main.go +++ b/cmd/bbrew/main.go @@ -19,12 +19,13 @@ func main() { fmt.Fprintf(os.Stderr, "Bold Brew - A TUI for Homebrew package management\n\n") fmt.Fprintf(os.Stderr, "Usage: bbrew [options]\n\n") fmt.Fprintf(os.Stderr, "Options:\n") - fmt.Fprintf(os.Stderr, " -f Path to Brewfile (show only packages from this Brewfile)\n") + fmt.Fprintf(os.Stderr, " -f Path or URL to Brewfile\n") fmt.Fprintf(os.Stderr, " -v, --version Show version information\n") fmt.Fprintf(os.Stderr, " -h, --help Show this help message\n") fmt.Fprintf(os.Stderr, "\nExamples:\n") fmt.Fprintf(os.Stderr, " bbrew Launch the TUI with all packages\n") - fmt.Fprintf(os.Stderr, " bbrew -f ~/Brewfile Launch with packages from Brewfile\n") + fmt.Fprintf(os.Stderr, " bbrew -f ~/Brewfile Launch with packages from local Brewfile\n") + fmt.Fprintf(os.Stderr, " bbrew -f https://... Launch with packages from remote Brewfile\n") } flag.Parse() @@ -35,15 +36,17 @@ func main() { os.Exit(0) } - // Validate Brewfile path if provided + // Resolve Brewfile path (handles both local and remote URLs) + var cleanup func() if *brewfilePath != "" { - if _, err := os.Stat(*brewfilePath); os.IsNotExist(err) { - fmt.Fprintf(os.Stderr, "Error: Brewfile not found: %s\n", *brewfilePath) - os.Exit(1) - } else if err != nil { - fmt.Fprintf(os.Stderr, "Error: Cannot access Brewfile: %v\n", err) + localPath, cleanupFn, err := services.ResolveBrewfilePath(*brewfilePath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } + *brewfilePath = localPath + cleanup = cleanupFn + defer cleanup() } // Initialize app service diff --git a/internal/services/brewfile.go b/internal/services/brewfile.go index a5d3f1b..ae1b29c 100644 --- a/internal/services/brewfile.go +++ b/internal/services/brewfile.go @@ -25,11 +25,70 @@ package services import ( "bbrew/internal/models" "fmt" + "io" + "net/http" "os" + "path/filepath" "sort" "strings" ) +// ResolveBrewfilePath resolves a Brewfile path which can be local or a remote URL. +// Returns the local file path and a cleanup function to call when done. +// For local files, cleanup is a no-op. For remote files, cleanup removes the temp file. +func ResolveBrewfilePath(pathOrURL string) (localPath string, cleanup func(), err error) { + // Check if it's a remote URL (HTTPS only for security) + if strings.HasPrefix(pathOrURL, "https://") { + localPath, err = downloadBrewfile(pathOrURL) + if err != nil { + return "", nil, err + } + // Return cleanup function that removes the temp file + cleanup = func() { os.Remove(localPath) } + return localPath, cleanup, nil + } + + // Local file - validate it exists + if _, err := os.Stat(pathOrURL); os.IsNotExist(err) { + return "", nil, fmt.Errorf("brewfile not found: %s", pathOrURL) + } else if err != nil { + return "", nil, fmt.Errorf("cannot access Brewfile: %w", err) + } + + // No cleanup needed for local files + return pathOrURL, func() {}, nil +} + +// downloadBrewfile downloads a remote Brewfile to a temporary file. +func downloadBrewfile(url string) (string, error) { + fmt.Fprintf(os.Stderr, "Downloading Brewfile from %s...\n", url) + + resp, err := http.Get(url) // #nosec G107 - URL is user-provided, HTTPS enforced + if err != nil { + return "", fmt.Errorf("failed to fetch URL: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("HTTP %d: %s", resp.StatusCode, resp.Status) + } + + // Create temp file + tempFile, err := os.CreateTemp(os.TempDir(), "bbrew-remote-*.brewfile") + if err != nil { + return "", fmt.Errorf("failed to create temp file: %w", err) + } + defer tempFile.Close() + + // Copy content + if _, err = io.Copy(tempFile, resp.Body); err != nil { + os.Remove(tempFile.Name()) + return "", fmt.Errorf("failed to save Brewfile: %w", err) + } + + return filepath.Clean(tempFile.Name()), nil +} + // parseBrewfileWithTaps parses a Brewfile and returns taps and packages separately. func parseBrewfileWithTaps(filepath string) (*models.BrewfileResult, error) { // #nosec G304 -- filepath is user-provided via CLI flag