package pkg import ( "encoding/json" "fmt" "os" "os/exec" "path/filepath" "sort" "strings" "time" "github.com/host-uk/core/pkg/cache" "github.com/host-uk/core/pkg/repos" "github.com/spf13/cobra" ) var ( searchOrg string searchPattern string searchType string searchLimit int searchRefresh bool ) // addPkgSearchCommand adds the 'pkg search' command. func addPkgSearchCommand(parent *cobra.Command) { searchCmd := &cobra.Command{ Use: "search", Short: "Search GitHub for packages", Long: "Searches GitHub for repositories matching a pattern.\n" + "Uses gh CLI for authenticated search. Results are cached for 1 hour.\n\n" + "Examples:\n" + " core pkg search # List all host-uk repos\n" + " core pkg search --pattern 'core-*' # Search for core-* repos\n" + " core pkg search --org mycompany # Search different org\n" + " core pkg search --refresh # Bypass cache", RunE: func(cmd *cobra.Command, args []string) error { org := searchOrg pattern := searchPattern limit := searchLimit if org == "" { org = "host-uk" } if pattern == "" { pattern = "*" } if limit == 0 { limit = 50 } return runPkgSearch(org, pattern, searchType, limit, searchRefresh) }, } searchCmd.Flags().StringVar(&searchOrg, "org", "", "GitHub organization (default: host-uk)") searchCmd.Flags().StringVar(&searchPattern, "pattern", "", "Repo name pattern (* for wildcard)") searchCmd.Flags().StringVar(&searchType, "type", "", "Filter by type in name (mod, services, plug, website)") searchCmd.Flags().IntVar(&searchLimit, "limit", 0, "Max results (default 50)") searchCmd.Flags().BoolVar(&searchRefresh, "refresh", false, "Bypass cache and fetch fresh data") parent.AddCommand(searchCmd) } type ghRepo struct { Name string `json:"name"` FullName string `json:"full_name"` Description string `json:"description"` Visibility string `json:"visibility"` UpdatedAt string `json:"updated_at"` Language string `json:"language"` } func runPkgSearch(org, pattern, repoType string, limit int, refresh bool) error { // Initialize cache in workspace .core/ directory var cacheDir string if regPath, err := repos.FindRegistry(); err == nil { cacheDir = filepath.Join(filepath.Dir(regPath), ".core", "cache") } c, err := cache.New(cacheDir, 0) if err != nil { c = nil } cacheKey := cache.GitHubReposKey(org) var ghRepos []ghRepo var fromCache bool // Try cache first (unless refresh requested) if c != nil && !refresh { if found, err := c.Get(cacheKey, &ghRepos); found && err == nil { fromCache = true age := c.Age(cacheKey) fmt.Printf("%s %s %s\n", dimStyle.Render("Cache:"), org, dimStyle.Render(fmt.Sprintf("(%s ago)", age.Round(time.Second)))) } } // Fetch from GitHub if not cached if !fromCache { if !ghAuthenticated() { return fmt.Errorf("gh CLI not authenticated. Run: gh auth login") } if os.Getenv("GH_TOKEN") != "" { fmt.Printf("%s GH_TOKEN env var is set - this may cause auth issues\n", dimStyle.Render("Note:")) fmt.Printf("%s Unset it with: unset GH_TOKEN\n\n", dimStyle.Render("")) } fmt.Printf("%s %s... ", dimStyle.Render("Fetching:"), org) cmd := exec.Command("gh", "repo", "list", org, "--json", "name,description,visibility,updatedAt,primaryLanguage", "--limit", fmt.Sprintf("%d", limit)) output, err := cmd.CombinedOutput() if err != nil { fmt.Println() errStr := strings.TrimSpace(string(output)) if strings.Contains(errStr, "401") || strings.Contains(errStr, "Bad credentials") { return fmt.Errorf("authentication failed - try: unset GH_TOKEN && gh auth login") } return fmt.Errorf("search failed: %s", errStr) } if err := json.Unmarshal(output, &ghRepos); err != nil { return fmt.Errorf("failed to parse results: %w", err) } if c != nil { _ = c.Set(cacheKey, ghRepos) } fmt.Printf("%s\n", successStyle.Render("✓")) } // Filter by glob pattern and type var filtered []ghRepo for _, r := range ghRepos { if !matchGlob(pattern, r.Name) { continue } if repoType != "" && !strings.Contains(r.Name, repoType) { continue } filtered = append(filtered, r) } if len(filtered) == 0 { fmt.Println("No repositories found matching pattern.") return nil } sort.Slice(filtered, func(i, j int) bool { return filtered[i].Name < filtered[j].Name }) fmt.Printf("Found %d repositories:\n\n", len(filtered)) for _, r := range filtered { visibility := "" if r.Visibility == "private" { visibility = dimStyle.Render(" [private]") } desc := r.Description if len(desc) > 50 { desc = desc[:47] + "..." } if desc == "" { desc = dimStyle.Render("(no description)") } fmt.Printf(" %s%s\n", repoNameStyle.Render(r.Name), visibility) fmt.Printf(" %s\n", desc) } fmt.Println() fmt.Printf("Install with: %s\n", dimStyle.Render(fmt.Sprintf("core pkg install %s/", org))) return nil } // matchGlob does simple glob matching with * wildcards func matchGlob(pattern, name string) bool { if pattern == "*" || pattern == "" { return true } parts := strings.Split(pattern, "*") pos := 0 for i, part := range parts { if part == "" { continue } idx := strings.Index(name[pos:], part) if idx == -1 { return false } if i == 0 && !strings.HasPrefix(pattern, "*") && idx != 0 { return false } pos += idx + len(part) } if !strings.HasSuffix(pattern, "*") && pos != len(name) { return false } return true }