From 548602b97dd9400dc7e4d4c27e8da0b18f5a0cb5 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 28 Jan 2026 14:50:55 +0000 Subject: [PATCH] feat(core): add setup and doctor commands for workspace bootstrap - Add `core setup` to clone repos from registry into packages/ - Supports --dry-run, --only type filter - Skips repos with clone: false - Detects existing clones - Add `core doctor` to check development environment - Checks git, gh, php, composer, node - Verifies SSH key exists - Checks gh CLI authentication - Shows workspace registry status - Update Repo struct with Clone field for skip control - Change Repo.Type from RepoType to string for flexibility Co-Authored-By: Claude Opus 4.5 --- cmd/core/cmd/doctor.go | 274 +++++++++++++++++++++++++++++++++++++++++ cmd/core/cmd/root.go | 2 + cmd/core/cmd/setup.go | 180 +++++++++++++++++++++++++++ pkg/repos/registry.go | 7 +- 4 files changed, 460 insertions(+), 3 deletions(-) create mode 100644 cmd/core/cmd/doctor.go create mode 100644 cmd/core/cmd/setup.go diff --git a/cmd/core/cmd/doctor.go b/cmd/core/cmd/doctor.go new file mode 100644 index 00000000..5805f7f9 --- /dev/null +++ b/cmd/core/cmd/doctor.go @@ -0,0 +1,274 @@ +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + + "github.com/Snider/Core/pkg/repos" + "github.com/leaanthony/clir" +) + +// AddDoctorCommand adds the 'doctor' command to the given parent command. +func AddDoctorCommand(parent *clir.Cli) { + var verbose bool + + doctorCmd := parent.NewSubCommand("doctor", "Check development environment") + doctorCmd.LongDescription("Checks that all required tools are installed and configured.\n" + + "Run this before `core setup` to ensure your environment is ready.") + + doctorCmd.BoolFlag("verbose", "Show detailed version information", &verbose) + + doctorCmd.Action(func() error { + return runDoctor(verbose) + }) +} + +type check struct { + name string + description string + command string + args []string + required bool + versionFlag string +} + +func runDoctor(verbose bool) error { + fmt.Println("Checking development environment...") + fmt.Println() + + checks := []check{ + // Required tools + { + name: "Git", + description: "Version control", + command: "git", + args: []string{"--version"}, + required: true, + versionFlag: "--version", + }, + { + name: "GitHub CLI", + description: "GitHub integration (issues, PRs, CI)", + command: "gh", + args: []string{"--version"}, + required: true, + versionFlag: "--version", + }, + { + name: "PHP", + description: "Laravel packages", + command: "php", + args: []string{"-v"}, + required: true, + versionFlag: "-v", + }, + { + name: "Composer", + description: "PHP dependencies", + command: "composer", + args: []string{"--version"}, + required: true, + versionFlag: "--version", + }, + { + name: "Node.js", + description: "Frontend builds", + command: "node", + args: []string{"--version"}, + required: true, + versionFlag: "--version", + }, + // Optional tools + { + name: "pnpm", + description: "Fast package manager", + command: "pnpm", + args: []string{"--version"}, + required: false, + versionFlag: "--version", + }, + { + name: "Claude Code", + description: "AI-assisted development", + command: "claude", + args: []string{"--version"}, + required: false, + versionFlag: "--version", + }, + { + name: "Docker", + description: "Container runtime", + command: "docker", + args: []string{"--version"}, + required: false, + versionFlag: "--version", + }, + } + + var passed, failed, optional int + + fmt.Println("Required:") + for _, c := range checks { + if !c.required { + continue + } + ok, version := runCheck(c) + if ok { + if verbose && version != "" { + fmt.Printf(" %s %s %s\n", successStyle.Render("✓"), c.name, dimStyle.Render(version)) + } else { + fmt.Printf(" %s %s\n", successStyle.Render("✓"), c.name) + } + passed++ + } else { + fmt.Printf(" %s %s - %s\n", errorStyle.Render("✗"), c.name, c.description) + failed++ + } + } + + fmt.Println("\nOptional:") + for _, c := range checks { + if c.required { + continue + } + ok, version := runCheck(c) + if ok { + if verbose && version != "" { + fmt.Printf(" %s %s %s\n", successStyle.Render("✓"), c.name, dimStyle.Render(version)) + } else { + fmt.Printf(" %s %s\n", successStyle.Render("✓"), c.name) + } + passed++ + } else { + fmt.Printf(" %s %s - %s\n", dimStyle.Render("○"), c.name, dimStyle.Render(c.description)) + optional++ + } + } + + // Check SSH + fmt.Println("\nGitHub Access:") + if checkGitHubSSH() { + fmt.Printf(" %s SSH key found\n", successStyle.Render("✓")) + } else { + fmt.Printf(" %s SSH key missing - run: ssh-keygen && gh ssh-key add\n", errorStyle.Render("✗")) + failed++ + } + + if checkGitHubCLI() { + fmt.Printf(" %s CLI authenticated\n", successStyle.Render("✓")) + } else { + fmt.Printf(" %s CLI authentication - run: gh auth login\n", errorStyle.Render("✗")) + failed++ + } + + // Check workspace + fmt.Println("\nWorkspace:") + registryPath, err := repos.FindRegistry() + if err == nil { + fmt.Printf(" %s Found repos.yaml at %s\n", successStyle.Render("✓"), registryPath) + + reg, err := repos.LoadRegistry(registryPath) + if err == nil { + basePath := reg.BasePath + if basePath == "" { + basePath = "./packages" + } + if !filepath.IsAbs(basePath) { + basePath = filepath.Join(filepath.Dir(registryPath), basePath) + } + if strings.HasPrefix(basePath, "~/") { + home, _ := os.UserHomeDir() + basePath = filepath.Join(home, basePath[2:]) + } + + // Count existing repos + allRepos := reg.List() + var cloned int + for _, repo := range allRepos { + repoPath := filepath.Join(basePath, repo.Name) + if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { + cloned++ + } + } + fmt.Printf(" %s %d/%d repos cloned\n", successStyle.Render("✓"), cloned, len(allRepos)) + } + } else { + fmt.Printf(" %s No repos.yaml found (run from workspace directory)\n", dimStyle.Render("○")) + } + + // Summary + fmt.Println() + if failed > 0 { + fmt.Printf("%s %d issues found\n", errorStyle.Render("Doctor:"), failed) + fmt.Println("\nInstall missing tools:") + printInstallInstructions() + return fmt.Errorf("%d required tools missing", failed) + } + + fmt.Printf("%s Environment ready\n", successStyle.Render("Doctor:")) + return nil +} + +func runCheck(c check) (bool, string) { + cmd := exec.Command(c.command, c.args...) + output, err := cmd.CombinedOutput() + if err != nil { + return false, "" + } + + // Extract first line as version + lines := strings.Split(strings.TrimSpace(string(output)), "\n") + if len(lines) > 0 { + return true, strings.TrimSpace(lines[0]) + } + return true, "" +} + +func checkGitHubSSH() bool { + // Just check if SSH keys exist - don't try to authenticate + // (key might be locked/passphrase protected) + home, err := os.UserHomeDir() + if err != nil { + return false + } + + sshDir := filepath.Join(home, ".ssh") + keyPatterns := []string{"id_rsa", "id_ed25519", "id_ecdsa", "id_dsa"} + + for _, key := range keyPatterns { + keyPath := filepath.Join(sshDir, key) + if _, err := os.Stat(keyPath); err == nil { + return true + } + } + + return false +} + +func checkGitHubCLI() bool { + cmd := exec.Command("gh", "auth", "status") + output, _ := cmd.CombinedOutput() + // Check for any successful login (even if there's also a failing token) + return strings.Contains(string(output), "Logged in to") +} + +func printInstallInstructions() { + switch runtime.GOOS { + case "darwin": + fmt.Println(" brew install git gh php composer node pnpm docker") + fmt.Println(" brew install --cask claude") + case "linux": + fmt.Println(" # Install via your package manager or:") + fmt.Println(" # Git: apt install git") + fmt.Println(" # GitHub CLI: https://cli.github.com/") + fmt.Println(" # PHP: apt install php8.3-cli") + fmt.Println(" # Node: https://nodejs.org/") + fmt.Println(" # pnpm: npm install -g pnpm") + default: + fmt.Println(" See documentation for your OS") + } +} diff --git a/cmd/core/cmd/root.go b/cmd/core/cmd/root.go index 3851f326..48ec4613 100644 --- a/cmd/core/cmd/root.go +++ b/cmd/core/cmd/root.go @@ -80,6 +80,8 @@ func Execute() error { AddImpactCommand(app) AddDocsCommand(app) AddCICommand(app) + AddSetupCommand(app) + AddDoctorCommand(app) // Run the application return app.Run() } diff --git a/cmd/core/cmd/setup.go b/cmd/core/cmd/setup.go new file mode 100644 index 00000000..7e20887c --- /dev/null +++ b/cmd/core/cmd/setup.go @@ -0,0 +1,180 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/Snider/Core/pkg/repos" + "github.com/leaanthony/clir" +) + +// AddSetupCommand adds the 'setup' command to the given parent command. +func AddSetupCommand(parent *clir.Cli) { + var registryPath string + var only string + var dryRun bool + + setupCmd := parent.NewSubCommand("setup", "Clone all repos from registry") + setupCmd.LongDescription("Clones all repositories defined in repos.yaml into packages/.\n" + + "Skips repos that already exist. Use --only to filter by type.") + + setupCmd.StringFlag("registry", "Path to repos.yaml (auto-detected if not specified)", ®istryPath) + setupCmd.StringFlag("only", "Only clone repos of these types (comma-separated: foundation,module,product)", &only) + setupCmd.BoolFlag("dry-run", "Show what would be cloned without cloning", &dryRun) + + setupCmd.Action(func() error { + return runSetup(registryPath, only, dryRun) + }) +} + +func runSetup(registryPath, only string, dryRun bool) error { + ctx := context.Background() + + // Find registry + var reg *repos.Registry + var err error + + if registryPath != "" { + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } else { + registryPath, err = repos.FindRegistry() + if err != nil { + return fmt.Errorf("no repos.yaml found - run this from a workspace directory") + } + reg, err = repos.LoadRegistry(registryPath) + if err != nil { + return fmt.Errorf("failed to load registry: %w", err) + } + } + + fmt.Printf("%s %s\n", dimStyle.Render("Registry:"), registryPath) + fmt.Printf("%s %s\n", dimStyle.Render("Org:"), reg.Org) + + // Determine base path for cloning + basePath := reg.BasePath + if basePath == "" { + basePath = "./packages" + } + // Resolve relative to registry location + if !filepath.IsAbs(basePath) { + basePath = filepath.Join(filepath.Dir(registryPath), basePath) + } + // Expand ~ + if strings.HasPrefix(basePath, "~/") { + home, _ := os.UserHomeDir() + basePath = filepath.Join(home, basePath[2:]) + } + + fmt.Printf("%s %s\n", dimStyle.Render("Target:"), basePath) + + // Parse type filter + var typeFilter map[string]bool + if only != "" { + typeFilter = make(map[string]bool) + for _, t := range strings.Split(only, ",") { + typeFilter[strings.TrimSpace(t)] = true + } + fmt.Printf("%s %s\n", dimStyle.Render("Filter:"), only) + } + + // Ensure base path exists + if !dryRun { + if err := os.MkdirAll(basePath, 0755); err != nil { + return fmt.Errorf("failed to create packages directory: %w", err) + } + } + + // Get repos to clone + allRepos := reg.List() + var toClone []*repos.Repo + var skipped, exists int + + for _, repo := range allRepos { + // Skip if type filter doesn't match + if typeFilter != nil && !typeFilter[repo.Type] { + skipped++ + continue + } + + // Skip if clone: false + if repo.Clone != nil && !*repo.Clone { + skipped++ + continue + } + + // Check if already exists + repoPath := filepath.Join(basePath, repo.Name) + if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil { + exists++ + continue + } + + toClone = append(toClone, repo) + } + + // Summary + fmt.Println() + fmt.Printf("%d to clone, %d exist, %d skipped\n", len(toClone), exists, skipped) + + if len(toClone) == 0 { + fmt.Println("\nNothing to clone.") + return nil + } + + if dryRun { + fmt.Println("\nWould clone:") + for _, repo := range toClone { + fmt.Printf(" %s (%s)\n", repoNameStyle.Render(repo.Name), repo.Type) + } + return nil + } + + // Clone repos + fmt.Println() + var succeeded, failed int + + for _, repo := range toClone { + 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) + if err != nil { + fmt.Printf("%s\n", errorStyle.Render("✗ "+err.Error())) + failed++ + } else { + fmt.Printf("%s\n", successStyle.Render("✓")) + succeeded++ + } + } + + // Summary + fmt.Println() + fmt.Printf("%s %d cloned", successStyle.Render("Done:"), succeeded) + if failed > 0 { + fmt.Printf(", %s", errorStyle.Render(fmt.Sprintf("%d failed", failed))) + } + if exists > 0 { + fmt.Printf(", %d already exist", exists) + } + fmt.Println() + + return nil +} + +func gitClone(ctx context.Context, url, path string) error { + cmd := exec.CommandContext(ctx, "git", "clone", url, path) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%s", strings.TrimSpace(string(output))) + } + return nil +} diff --git a/pkg/repos/registry.go b/pkg/repos/registry.go index 1f393791..3ae5d8c3 100644 --- a/pkg/repos/registry.go +++ b/pkg/repos/registry.go @@ -41,12 +41,13 @@ const ( // Repo represents a single repository in the registry. type Repo struct { Name string `yaml:"-"` // Set from map key - Type RepoType `yaml:"type"` + Type string `yaml:"type"` DependsOn []string `yaml:"depends_on"` Description string `yaml:"description"` Docs bool `yaml:"docs"` CI string `yaml:"ci"` Domain string `yaml:"domain,omitempty"` + Clone *bool `yaml:"clone,omitempty"` // nil = true, false = skip cloning // Computed fields Path string `yaml:"-"` // Full path to repo directory @@ -153,7 +154,7 @@ func ScanDirectory(dir string) (*Registry, error) { repo := &Repo{ Name: entry.Name(), Path: repoPath, - Type: RepoTypeModule, // Default type + Type: "module", // Default type } reg.Repos[entry.Name()] = repo @@ -231,7 +232,7 @@ func (r *Registry) Get(name string) (*Repo, bool) { } // ByType returns repos filtered by type. -func (r *Registry) ByType(t RepoType) []*Repo { +func (r *Registry) ByType(t string) []*Repo { var repos []*Repo for _, repo := range r.Repos { if repo.Type == t {