diff --git a/cmd/core/cmd/install.go b/cmd/core/cmd/install.go new file mode 100644 index 00000000..18066b20 --- /dev/null +++ b/cmd/core/cmd/install.go @@ -0,0 +1,170 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Snider/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 letheanvpn/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/root.go b/cmd/core/cmd/root.go index 48ec4613..0fa0f72f 100644 --- a/cmd/core/cmd/root.go +++ b/cmd/core/cmd/root.go @@ -82,6 +82,8 @@ func Execute() error { AddCICommand(app) AddSetupCommand(app) AddDoctorCommand(app) + AddSearchCommand(app) + AddInstallCommand(app) // Run the application return app.Run() } diff --git a/cmd/core/cmd/search.go b/cmd/core/cmd/search.go new file mode 100644 index 00000000..fa8a5b79 --- /dev/null +++ b/cmd/core/cmd/search.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "sort" + "strings" + + "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 + + 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.\n\n" + + "Examples:\n" + + " core search --org host-uk --pattern 'core-*'\n" + + " core search --org mycompany --pattern '*-mod-*'\n" + + " core search --org letheanvpn --pattern '*'") + + 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.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) + }) +} + +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) error { + 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("Searching:"), org) + if pattern != "" && pattern != "*" { + fmt.Printf(" (filter: %s)", pattern) + } + if repoType != "" { + fmt.Printf(" (type: %s)", repoType) + } + fmt.Println() + fmt.Println() + + // 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 { + 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) + } + + var repos []ghRepo + if err := json.Unmarshal(output, &repos); err != nil { + return fmt.Errorf("failed to parse results: %w", err) + } + + // 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/cmd/setup.go b/cmd/core/cmd/setup.go index 7e20887c..7762cdce 100644 --- a/cmd/core/cmd/setup.go +++ b/cmd/core/cmd/setup.go @@ -144,9 +144,8 @@ func runSetup(registryPath, only string, dryRun bool) error { fmt.Printf(" %s %s... ", dimStyle.Render("Cloning"), repo.Name) repoPath := filepath.Join(basePath, repo.Name) - cloneURL := fmt.Sprintf("git@github.com:%s/%s.git", reg.Org, repo.Name) - err := gitClone(ctx, cloneURL, repoPath) + err := gitClone(ctx, reg.Org, repo.Name, repoPath) if err != nil { fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error())) failed++ @@ -170,7 +169,26 @@ func runSetup(registryPath, only string, dryRun bool) error { return nil } -func gitClone(ctx context.Context, url, path string) error { +func gitClone(ctx context.Context, org, repo, path string) error { + // Try gh clone first with HTTPS (works without SSH keys) + if ghAuthenticated() { + // Use HTTPS URL directly to bypass git_protocol config + httpsURL := fmt.Sprintf("https://github.com/%s/%s.git", org, repo) + cmd := exec.CommandContext(ctx, "gh", "repo", "clone", httpsURL, path) + output, err := cmd.CombinedOutput() + if err == nil { + return nil + } + errStr := strings.TrimSpace(string(output)) + // Only fall through to SSH if it's an auth error + if !strings.Contains(errStr, "Permission denied") && + !strings.Contains(errStr, "could not read") { + return fmt.Errorf("%s", errStr) + } + } + + // Fallback to git clone via SSH + url := fmt.Sprintf("git@github.com:%s/%s.git", org, repo) cmd := exec.CommandContext(ctx, "git", "clone", url, path) output, err := cmd.CombinedOutput() if err != nil { @@ -178,3 +196,9 @@ func gitClone(ctx context.Context, url, path string) error { } return nil } + +func ghAuthenticated() bool { + cmd := exec.Command("gh", "auth", "status") + output, _ := cmd.CombinedOutput() + return strings.Contains(string(output), "Logged in") +}