diff --git a/.claude/skills/core/SKILL.md b/.claude/skills/core/SKILL.md index 1dff1128..de11e87b 100644 --- a/.claude/skills/core/SKILL.md +++ b/.claude/skills/core/SKILL.md @@ -33,6 +33,10 @@ The `core` command provides a unified interface for Go/Wails development, multi- | Check CI | `core ci` | GitHub Actions status | | Generate SDK | `core sdk` | Generate API clients from OpenAPI | | Sync docs | `core docs sync` | Sync docs across repos | +| Search packages | `core pkg search ` | GitHub search for core-* repos | +| Install package | `core pkg install ` | Clone and register package | +| Update packages | `core pkg update` | Pull latest for all packages | +| Run VM | `core vm run ` | Run LinuxKit VM | ## Testing @@ -186,12 +190,32 @@ core doctor # Clone all repos from registry core setup +``` -# Search GitHub repos -core search +## Package Management -# Clone a specific repo -core install +Manage host-uk/core-* packages and repositories. + +```bash +# Search GitHub for packages +core pkg search +core pkg search core- # Find all core-* packages +core pkg search --org host-uk # Search specific org + +# Install/clone a package +core pkg install core-api +core pkg install host-uk/core-api # Full name + +# List installed packages +core pkg list +core pkg list --format json # JSON output + +# Update installed packages +core pkg update # Update all +core pkg update core-api # Update specific package + +# Check for outdated packages +core pkg outdated ``` ## PHP Development @@ -418,6 +442,12 @@ Need GitHub info? Setting up environment? └── Check: core doctor └── Clone all: core setup + +Managing packages? + └── Search: core pkg search + └── Install: core pkg install + └── Update: core pkg update + └── Check outdated: core pkg outdated ``` ## Common Mistakes @@ -434,6 +464,8 @@ Setting up environment? | Manual commits across repos | `core commit` | Consistent messages, Co-Authored-By | | Manual Coolify deploys | `core php deploy` | Tracked, scriptable | | Raw `linuxkit run` | `core vm run` | Unified interface, templates | +| `gh repo clone` | `core pkg install` | Auto-detects org, adds to registry | +| Manual GitHub search | `core pkg search` | Filtered to org, formatted output | ## Configuration diff --git a/cmd/core/cmd/install.go b/cmd/core/cmd/install.go deleted file mode 100644 index d5c123ed..00000000 --- a/cmd/core/cmd/install.go +++ /dev/null @@ -1,170 +0,0 @@ -package cmd - -import ( - "context" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/host-uk/core/pkg/repos" - "github.com/leaanthony/clir" -) - -// AddInstallCommand adds the 'install' command to the given parent command. -func AddInstallCommand(parent *clir.Cli) { - var targetDir string - var addToRegistry bool - var repo string - - installCmd := parent.NewSubCommand("install", "Install a repo from GitHub") - installCmd.LongDescription("Clones a repository from GitHub.\n\n" + - "Examples:\n" + - " core install --repo host-uk/core-php\n" + - " core install --repo LetheanNetwork/lthn-mod-wallet\n" + - " core install --repo host-uk/core-tenant --dir ./packages") - - installCmd.StringFlag("repo", "Repository to install (org/repo format)", &repo) - installCmd.StringFlag("dir", "Target directory (default: ./packages or current dir)", &targetDir) - installCmd.BoolFlag("add", "Add to repos.yaml registry", &addToRegistry) - - installCmd.Action(func() error { - if repo == "" { - return fmt.Errorf("--repo is required (e.g., --repo host-uk/core-php)") - } - return runInstall(repo, targetDir, addToRegistry) - }) -} - -func runInstall(repoArg, targetDir string, addToRegistry bool) error { - ctx := context.Background() - - // Parse org/repo - parts := strings.Split(repoArg, "/") - if len(parts) != 2 { - return fmt.Errorf("invalid repo format: use org/repo (e.g., host-uk/core-php)") - } - org, repoName := parts[0], parts[1] - - // Determine target directory - if targetDir == "" { - // Try to find registry and use its base path - if regPath, err := repos.FindRegistry(); err == nil { - if reg, err := repos.LoadRegistry(regPath); err == nil { - targetDir = reg.BasePath - if targetDir == "" { - targetDir = "./packages" - } - if !filepath.IsAbs(targetDir) { - targetDir = filepath.Join(filepath.Dir(regPath), targetDir) - } - } - } - - // Fallback to current directory - if targetDir == "" { - targetDir = "." - } - } - - // Expand ~ in path - if strings.HasPrefix(targetDir, "~/") { - home, _ := os.UserHomeDir() - targetDir = filepath.Join(home, targetDir[2:]) - } - - repoPath := filepath.Join(targetDir, repoName) - - // Check if already exists - if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { - fmt.Printf("%s %s already exists at %s\n", dimStyle.Render("Skip:"), repoName, repoPath) - return nil - } - - // Ensure target directory exists - if err := os.MkdirAll(targetDir, 0755); err != nil { - return fmt.Errorf("failed to create directory: %w", err) - } - - fmt.Printf("%s %s/%s\n", dimStyle.Render("Installing:"), org, repoName) - fmt.Printf("%s %s\n", dimStyle.Render("Target:"), repoPath) - fmt.Println() - - // Clone - fmt.Printf(" %s... ", dimStyle.Render("Cloning")) - err := gitClone(ctx, org, repoName, repoPath) - if err != nil { - fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error())) - return err - } - fmt.Printf("%s\n", successStyle.Render("✓")) - - // Add to registry if requested - if addToRegistry { - if err := addToRegistryFile(org, repoName); err != nil { - fmt.Printf(" %s add to registry: %s\n", errorStyle.Render("✗"), err) - } else { - fmt.Printf(" %s added to repos.yaml\n", successStyle.Render("✓")) - } - } - - fmt.Println() - fmt.Printf("%s Installed %s\n", successStyle.Render("Done:"), repoName) - - return nil -} - -func addToRegistryFile(org, repoName string) error { - regPath, err := repos.FindRegistry() - if err != nil { - return fmt.Errorf("no repos.yaml found") - } - - reg, err := repos.LoadRegistry(regPath) - if err != nil { - return err - } - - // Check if already in registry - if _, exists := reg.Get(repoName); exists { - return nil // Already exists - } - - // Append to file (simple approach - just add YAML at end) - f, err := os.OpenFile(regPath, os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return err - } - defer f.Close() - - // Detect type from name - repoType := detectRepoType(repoName) - - entry := fmt.Sprintf("\n %s:\n type: %s\n description: (installed via core install)\n", - repoName, repoType) - - _, err = f.WriteString(entry) - return err -} - -func detectRepoType(name string) string { - lower := strings.ToLower(name) - - if strings.Contains(lower, "-mod-") || strings.HasSuffix(lower, "-mod") { - return "module" - } - if strings.Contains(lower, "-plug-") || strings.HasSuffix(lower, "-plug") { - return "plugin" - } - if strings.Contains(lower, "-services-") || strings.HasSuffix(lower, "-services") { - return "service" - } - if strings.Contains(lower, "-website-") || strings.HasSuffix(lower, "-website") { - return "website" - } - if strings.HasPrefix(lower, "core-") { - return "module" - } - - return "package" -} diff --git a/cmd/core/cmd/pkg.go b/cmd/core/cmd/pkg.go new file mode 100644 index 00000000..36d95b19 --- /dev/null +++ b/cmd/core/cmd/pkg.go @@ -0,0 +1,603 @@ +package cmd + +import ( + "context" + "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/leaanthony/clir" +) + +// AddPkgCommands adds the 'pkg' command and subcommands for package management. +func AddPkgCommands(parent *clir.Cli) { + pkgCmd := parent.NewSubCommand("pkg", "Package management for core-* repos") + pkgCmd.LongDescription("Manage host-uk/core-* packages and repositories.\n\n" + + "Commands:\n" + + " search Search GitHub for packages\n" + + " install Clone a package from GitHub\n" + + " list List installed packages\n" + + " update Update installed packages\n" + + " outdated Check for outdated packages") + + addPkgSearchCommand(pkgCmd) + addPkgInstallCommand(pkgCmd) + addPkgListCommand(pkgCmd) + addPkgUpdateCommand(pkgCmd) + addPkgOutdatedCommand(pkgCmd) +} + +// addPkgSearchCommand adds the 'pkg search' command. +func addPkgSearchCommand(parent *clir.Command) { + var org string + var pattern string + var repoType string + var limit int + var refresh bool + + searchCmd := parent.NewSubCommand("search", "Search GitHub for packages") + searchCmd.LongDescription("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") + + searchCmd.StringFlag("org", "GitHub organization (default: host-uk)", &org) + searchCmd.StringFlag("pattern", "Repo name pattern (* for wildcard)", &pattern) + searchCmd.StringFlag("type", "Filter by type in name (mod, services, plug, website)", &repoType) + searchCmd.IntFlag("limit", "Max results (default 50)", &limit) + searchCmd.BoolFlag("refresh", "Bypass cache and fetch fresh data", &refresh) + + searchCmd.Action(func() error { + if org == "" { + org = "host-uk" + } + if pattern == "" { + pattern = "*" + } + if limit == 0 { + limit = 50 + } + return runPkgSearch(org, pattern, repoType, limit, refresh) + }) +} + +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 +} + +// addPkgInstallCommand adds the 'pkg install' command. +func addPkgInstallCommand(parent *clir.Command) { + var targetDir string + var addToRegistry bool + + installCmd := parent.NewSubCommand("install", "Clone a package from GitHub") + installCmd.LongDescription("Clones a repository from GitHub.\n\n" + + "Examples:\n" + + " core pkg install host-uk/core-php\n" + + " core pkg install host-uk/core-tenant --dir ./packages\n" + + " core pkg install host-uk/core-admin --add") + + installCmd.StringFlag("dir", "Target directory (default: ./packages or current dir)", &targetDir) + installCmd.BoolFlag("add", "Add to repos.yaml registry", &addToRegistry) + + installCmd.Action(func() error { + args := installCmd.OtherArgs() + if len(args) == 0 { + return fmt.Errorf("repository is required (e.g., core pkg install host-uk/core-php)") + } + return runPkgInstall(args[0], targetDir, addToRegistry) + }) +} + +func runPkgInstall(repoArg, targetDir string, addToRegistry bool) error { + ctx := context.Background() + + // Parse org/repo + parts := strings.Split(repoArg, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repo format: use org/repo (e.g., host-uk/core-php)") + } + org, repoName := parts[0], parts[1] + + // Determine target directory + if targetDir == "" { + if regPath, err := repos.FindRegistry(); err == nil { + if reg, err := repos.LoadRegistry(regPath); err == nil { + targetDir = reg.BasePath + if targetDir == "" { + targetDir = "./packages" + } + if !filepath.IsAbs(targetDir) { + targetDir = filepath.Join(filepath.Dir(regPath), targetDir) + } + } + } + if targetDir == "" { + targetDir = "." + } + } + + if strings.HasPrefix(targetDir, "~/") { + home, _ := os.UserHomeDir() + targetDir = filepath.Join(home, targetDir[2:]) + } + + repoPath := filepath.Join(targetDir, repoName) + + if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { + fmt.Printf("%s %s already exists at %s\n", dimStyle.Render("Skip:"), repoName, repoPath) + return nil + } + + if err := os.MkdirAll(targetDir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + fmt.Printf("%s %s/%s\n", dimStyle.Render("Installing:"), org, repoName) + fmt.Printf("%s %s\n", dimStyle.Render("Target:"), repoPath) + fmt.Println() + + fmt.Printf(" %s... ", dimStyle.Render("Cloning")) + err := gitClone(ctx, org, repoName, repoPath) + if err != nil { + fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error())) + return err + } + fmt.Printf("%s\n", successStyle.Render("✓")) + + if addToRegistry { + if err := addToRegistryFile(org, repoName); err != nil { + fmt.Printf(" %s add to registry: %s\n", errorStyle.Render("✗"), err) + } else { + fmt.Printf(" %s added to repos.yaml\n", successStyle.Render("✓")) + } + } + + fmt.Println() + fmt.Printf("%s Installed %s\n", successStyle.Render("Done:"), repoName) + + return nil +} + +func addToRegistryFile(org, repoName string) error { + regPath, err := repos.FindRegistry() + if err != nil { + return fmt.Errorf("no repos.yaml found") + } + + reg, err := repos.LoadRegistry(regPath) + if err != nil { + return err + } + + if _, exists := reg.Get(repoName); exists { + return nil + } + + f, err := os.OpenFile(regPath, os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return err + } + defer f.Close() + + repoType := detectRepoType(repoName) + entry := fmt.Sprintf("\n %s:\n type: %s\n description: (installed via core pkg install)\n", + repoName, repoType) + + _, err = f.WriteString(entry) + return err +} + +func detectRepoType(name string) string { + lower := strings.ToLower(name) + if strings.Contains(lower, "-mod-") || strings.HasSuffix(lower, "-mod") { + return "module" + } + if strings.Contains(lower, "-plug-") || strings.HasSuffix(lower, "-plug") { + return "plugin" + } + if strings.Contains(lower, "-services-") || strings.HasSuffix(lower, "-services") { + return "service" + } + if strings.Contains(lower, "-website-") || strings.HasSuffix(lower, "-website") { + return "website" + } + if strings.HasPrefix(lower, "core-") { + return "package" + } + return "package" +} + +// addPkgListCommand adds the 'pkg list' command. +func addPkgListCommand(parent *clir.Command) { + listCmd := parent.NewSubCommand("list", "List installed packages") + listCmd.LongDescription("Lists all packages in the current workspace.\n\n" + + "Reads from repos.yaml or scans for git repositories.\n\n" + + "Examples:\n" + + " core pkg list") + + listCmd.Action(func() error { + return runPkgList() + }) +} + +func runPkgList() error { + regPath, err := repos.FindRegistry() + if err != nil { + return fmt.Errorf("no repos.yaml found - run from workspace directory") + } + + reg, err := repos.LoadRegistry(regPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + + basePath := reg.BasePath + if basePath == "" { + basePath = "." + } + if !filepath.IsAbs(basePath) { + basePath = filepath.Join(filepath.Dir(regPath), basePath) + } + + allRepos := reg.List() + if len(allRepos) == 0 { + fmt.Println("No packages in registry.") + return nil + } + + fmt.Printf("%s\n\n", repoNameStyle.Render("Installed Packages")) + + var installed, missing int + for _, r := range allRepos { + repoPath := filepath.Join(basePath, r.Name) + exists := false + if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { + exists = true + installed++ + } else { + missing++ + } + + status := successStyle.Render("✓") + if !exists { + status = dimStyle.Render("○") + } + + desc := r.Description + if len(desc) > 40 { + desc = desc[:37] + "..." + } + if desc == "" { + desc = dimStyle.Render("(no description)") + } + + fmt.Printf(" %s %s\n", status, repoNameStyle.Render(r.Name)) + fmt.Printf(" %s\n", desc) + } + + fmt.Println() + fmt.Printf("%s %d installed, %d missing\n", dimStyle.Render("Total:"), installed, missing) + + if missing > 0 { + fmt.Printf("\nInstall missing: %s\n", dimStyle.Render("core setup")) + } + + return nil +} + +// addPkgUpdateCommand adds the 'pkg update' command. +func addPkgUpdateCommand(parent *clir.Command) { + var all bool + + updateCmd := parent.NewSubCommand("update", "Update installed packages") + updateCmd.LongDescription("Pulls latest changes for installed packages.\n\n" + + "Examples:\n" + + " core pkg update core-php # Update specific package\n" + + " core pkg update --all # Update all packages") + + updateCmd.BoolFlag("all", "Update all packages", &all) + + updateCmd.Action(func() error { + args := updateCmd.OtherArgs() + if !all && len(args) == 0 { + return fmt.Errorf("specify package name or use --all") + } + return runPkgUpdate(args, all) + }) +} + +func runPkgUpdate(packages []string, all bool) error { + regPath, err := repos.FindRegistry() + if err != nil { + return fmt.Errorf("no repos.yaml found") + } + + reg, err := repos.LoadRegistry(regPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + + basePath := reg.BasePath + if basePath == "" { + basePath = "." + } + if !filepath.IsAbs(basePath) { + basePath = filepath.Join(filepath.Dir(regPath), basePath) + } + + var toUpdate []string + if all { + for _, r := range reg.List() { + toUpdate = append(toUpdate, r.Name) + } + } else { + toUpdate = packages + } + + fmt.Printf("%s Updating %d package(s)\n\n", dimStyle.Render("Update:"), len(toUpdate)) + + var updated, skipped, failed int + for _, name := range toUpdate { + repoPath := filepath.Join(basePath, name) + + if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) { + fmt.Printf(" %s %s (not installed)\n", dimStyle.Render("○"), name) + skipped++ + continue + } + + fmt.Printf(" %s %s... ", dimStyle.Render("↓"), name) + + cmd := exec.Command("git", "-C", repoPath, "pull", "--ff-only") + output, err := cmd.CombinedOutput() + if err != nil { + fmt.Printf("%s\n", errorStyle.Render("✗")) + fmt.Printf(" %s\n", strings.TrimSpace(string(output))) + failed++ + continue + } + + if strings.Contains(string(output), "Already up to date") { + fmt.Printf("%s\n", dimStyle.Render("up to date")) + } else { + fmt.Printf("%s\n", successStyle.Render("✓")) + } + updated++ + } + + fmt.Println() + fmt.Printf("%s %d updated, %d skipped, %d failed\n", + dimStyle.Render("Done:"), updated, skipped, failed) + + return nil +} + +// addPkgOutdatedCommand adds the 'pkg outdated' command. +func addPkgOutdatedCommand(parent *clir.Command) { + outdatedCmd := parent.NewSubCommand("outdated", "Check for outdated packages") + outdatedCmd.LongDescription("Checks which packages have unpulled commits.\n\n" + + "Examples:\n" + + " core pkg outdated") + + outdatedCmd.Action(func() error { + return runPkgOutdated() + }) +} + +func runPkgOutdated() error { + regPath, err := repos.FindRegistry() + if err != nil { + return fmt.Errorf("no repos.yaml found") + } + + reg, err := repos.LoadRegistry(regPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + + basePath := reg.BasePath + if basePath == "" { + basePath = "." + } + if !filepath.IsAbs(basePath) { + basePath = filepath.Join(filepath.Dir(regPath), basePath) + } + + fmt.Printf("%s Checking for updates...\n\n", dimStyle.Render("Outdated:")) + + var outdated, upToDate, notInstalled int + var outdatedList []string + + for _, r := range reg.List() { + repoPath := filepath.Join(basePath, r.Name) + + if _, err := os.Stat(filepath.Join(repoPath, ".git")); os.IsNotExist(err) { + notInstalled++ + continue + } + + // Fetch updates + exec.Command("git", "-C", repoPath, "fetch", "--quiet").Run() + + // Check if behind + cmd := exec.Command("git", "-C", repoPath, "rev-list", "--count", "HEAD..@{u}") + output, err := cmd.Output() + if err != nil { + continue + } + + count := strings.TrimSpace(string(output)) + if count != "0" { + fmt.Printf(" %s %s (%s commits behind)\n", + errorStyle.Render("↓"), repoNameStyle.Render(r.Name), count) + outdated++ + outdatedList = append(outdatedList, r.Name) + } else { + upToDate++ + } + } + + fmt.Println() + if outdated == 0 { + fmt.Printf("%s All packages up to date\n", successStyle.Render("Done:")) + } else { + fmt.Printf("%s %d outdated, %d up to date\n", + dimStyle.Render("Summary:"), outdated, upToDate) + fmt.Printf("\nUpdate with: %s\n", dimStyle.Render("core pkg update --all")) + } + + return nil +} diff --git a/cmd/core/cmd/root.go b/cmd/core/cmd/root.go index 5a3b7b3a..056a79de 100644 --- a/cmd/core/cmd/root.go +++ b/cmd/core/cmd/root.go @@ -44,8 +44,7 @@ func Execute() error { AddCICommand(app) AddSetupCommand(app) AddDoctorCommand(app) - AddSearchCommand(app) - AddInstallCommand(app) + AddPkgCommands(app) AddReleaseCommand(app) AddContainerCommands(app) AddPHPCommands(app) diff --git a/cmd/core/cmd/search.go b/cmd/core/cmd/search.go deleted file mode 100644 index 54742a7e..00000000 --- a/cmd/core/cmd/search.go +++ /dev/null @@ -1,215 +0,0 @@ -package cmd - -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/leaanthony/clir" -) - -// AddSearchCommand adds the 'search' command to the given parent command. -func AddSearchCommand(parent *clir.Cli) { - var org string - var pattern string - var repoType string - var limit int - var refresh bool - - searchCmd := parent.NewSubCommand("search", "Search GitHub for repos by pattern") - searchCmd.LongDescription("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 search --org host-uk --pattern 'core-*'\n" + - " core search --org mycompany --pattern '*-mod-*'\n" + - " core search --org LetheanNetwork --refresh") - - searchCmd.StringFlag("org", "GitHub organization to search (required)", &org) - searchCmd.StringFlag("pattern", "Repo name pattern (* for wildcard)", &pattern) - searchCmd.StringFlag("type", "Filter by type in name (mod, services, plug, website)", &repoType) - searchCmd.IntFlag("limit", "Max results (default 50)", &limit) - searchCmd.BoolFlag("refresh", "Bypass cache and fetch fresh data", &refresh) - - searchCmd.Action(func() error { - if org == "" { - return fmt.Errorf("--org is required") - } - if pattern == "" { - pattern = "*" - } - if limit == 0 { - limit = 50 - } - return runSearch(org, pattern, repoType, limit, refresh) - }) -} - -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 runSearch(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 { - // Cache init failed, continue without cache - c = nil - } - - cacheKey := cache.GitHubReposKey(org) - var repos []ghRepo - var fromCache bool - - // Try cache first (unless refresh requested) - if c != nil && !refresh { - if found, err := c.Get(cacheKey, &repos); 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") - } - - // Check for bad GH_TOKEN which can override keyring auth - 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) - - // Always use gh repo list (more reliable than gh search repos) - 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, &repos); err != nil { - return fmt.Errorf("failed to parse results: %w", err) - } - - // Cache the results - if c != nil { - _ = c.Set(cacheKey, repos) - } - - fmt.Printf("%s\n", successStyle.Render("✓")) - } - - // Filter by glob pattern and type - var filtered []ghRepo - for _, r := range repos { - // Check glob pattern - if !matchGlob(pattern, r.Name) { - continue - } - // Check type filter (e.g., "mod" matches "*-mod-*" or "*-mod") - 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 by name - sort.Slice(filtered, func(i, j int) bool { - return filtered[i].Name < filtered[j].Name - }) - - // Display results - 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 install %s/", org))) - - return nil -} - -// matchGlob does simple glob matching with * wildcards -func matchGlob(pattern, name string) bool { - if pattern == "*" || pattern == "" { - return true - } - - // Simple glob: split by * and check if all parts exist in order - 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 - } - - // First part must be at start if pattern doesn't start with * - if i == 0 && !strings.HasPrefix(pattern, "*") && idx != 0 { - return false - } - - pos += idx + len(part) - } - - // Last part must be at end if pattern doesn't end with * - if !strings.HasSuffix(pattern, "*") && pos != len(name) { - return false - } - - return true -} diff --git a/cmd/core/go.mod b/cmd/core/go.mod index bc9c1534..17ae7187 100644 --- a/cmd/core/go.mod +++ b/cmd/core/go.mod @@ -4,7 +4,6 @@ go 1.25.5 require ( github.com/charmbracelet/lipgloss v1.1.0 - github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be github.com/host-uk/core/pkg/build v0.0.0 github.com/host-uk/core/pkg/cache v0.0.0-20260128153551-31712611be1c github.com/host-uk/core/pkg/git v0.0.0 diff --git a/cmd/core/go.sum b/cmd/core/go.sum index 2ff356cc..676ba16c 100644 --- a/cmd/core/go.sum +++ b/cmd/core/go.sum @@ -10,8 +10,6 @@ github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0G github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= -github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ= -github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= diff --git a/pkg/devops/go.mod b/pkg/devops/go.mod index 181702a8..f78ffbbf 100644 --- a/pkg/devops/go.mod +++ b/pkg/devops/go.mod @@ -4,6 +4,11 @@ go 1.25 require github.com/host-uk/core v0.0.0 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + github.com/kr/pretty v0.3.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 +) replace github.com/host-uk/core => ../.. diff --git a/pkg/devops/go.sum b/pkg/devops/go.sum index bed8a887..90355bb1 100644 --- a/pkg/devops/go.sum +++ b/pkg/devops/go.sum @@ -1,6 +1,10 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=