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 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-01-28 14:50:55 +00:00
parent 61e446b5ff
commit 548602b97d
4 changed files with 460 additions and 3 deletions

274
cmd/core/cmd/doctor.go Normal file
View file

@ -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")
}
}

View file

@ -80,6 +80,8 @@ func Execute() error {
AddImpactCommand(app)
AddDocsCommand(app)
AddCICommand(app)
AddSetupCommand(app)
AddDoctorCommand(app)
// Run the application
return app.Run()
}

180
cmd/core/cmd/setup.go Normal file
View file

@ -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)", &registryPath)
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
}

View file

@ -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 {