* feat(help): Add CLI help command Fixes #136 * chore: remove binary * feat(mcp): Add TCP transport Fixes #126 * feat(io): Migrate pkg/mcp to use Medium abstraction Fixes #103 * chore(io): Migrate internal/cmd/docs/* to Medium abstraction Fixes #113 * chore(io): Migrate internal/cmd/dev/* to Medium abstraction Fixes #114 * chore(io): Migrate internal/cmd/setup/* to Medium abstraction * chore(io): Complete migration of internal/cmd/dev/* to Medium abstraction * chore(io): Migrate internal/cmd/sdk, pkgcmd, and workspace to Medium abstraction * style: fix formatting in internal/variants Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(io): simplify local Medium implementation Rewrote to match the simpler TypeScript pattern: - path() sanitizes and returns string directly - Each method calls path() once - No complex symlink validation - Less code, less attack surface Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test(mcp): update sandboxing tests for simplified Medium The simplified io/local.Medium implementation: - Sanitizes .. to . (no error, path is cleaned) - Allows absolute paths through (caller validates if needed) - Follows symlinks (no traversal blocking) Update tests to match this simplified behavior. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(updater): resolve PkgVersion duplicate declaration Remove var PkgVersion from updater.go since go generate creates const PkgVersion in version.go. Track version.go in git to ensure builds work without running go generate first. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
264 lines
7.3 KiB
Go
264 lines
7.3 KiB
Go
// cmd_registry.go implements registry mode for cloning packages.
|
|
//
|
|
// Registry mode is activated when a repos.yaml exists. It reads the registry
|
|
// and clones all (or selected) packages into the configured packages directory.
|
|
|
|
package setup
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/host-uk/core/internal/cmd/workspace"
|
|
"github.com/host-uk/core/pkg/cli"
|
|
"github.com/host-uk/core/pkg/i18n"
|
|
coreio "github.com/host-uk/core/pkg/io"
|
|
"github.com/host-uk/core/pkg/repos"
|
|
)
|
|
|
|
// runRegistrySetup loads a registry from path and runs setup.
|
|
func runRegistrySetup(ctx context.Context, registryPath, only string, dryRun, all, runBuild bool) error {
|
|
reg, err := repos.LoadRegistry(registryPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to load registry: %w", err)
|
|
}
|
|
|
|
// Check workspace config for default_only if no filter specified
|
|
if only == "" {
|
|
registryDir := filepath.Dir(registryPath)
|
|
if wsConfig, err := workspace.LoadConfig(registryDir); err == nil && wsConfig != nil && len(wsConfig.DefaultOnly) > 0 {
|
|
only = strings.Join(wsConfig.DefaultOnly, ",")
|
|
}
|
|
}
|
|
|
|
return runRegistrySetupWithReg(ctx, reg, registryPath, only, dryRun, all, runBuild)
|
|
}
|
|
|
|
// runRegistrySetupWithReg runs setup with an already-loaded registry.
|
|
func runRegistrySetupWithReg(ctx context.Context, reg *repos.Registry, registryPath, only string, dryRun, all, runBuild bool) error {
|
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("registry")), registryPath)
|
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.T("cmd.setup.org_label")), reg.Org)
|
|
|
|
registryDir := filepath.Dir(registryPath)
|
|
|
|
// Determine base path for cloning
|
|
basePath := reg.BasePath
|
|
if basePath == "" {
|
|
// Load workspace config to see if packages_dir is set (ignore errors, fall back to default)
|
|
wsConfig, _ := workspace.LoadConfig(registryDir)
|
|
if wsConfig != nil && wsConfig.PackagesDir != "" {
|
|
basePath = wsConfig.PackagesDir
|
|
} else {
|
|
basePath = "./packages"
|
|
}
|
|
}
|
|
|
|
// Expand ~
|
|
if strings.HasPrefix(basePath, "~/") {
|
|
home, _ := os.UserHomeDir()
|
|
basePath = filepath.Join(home, basePath[2:])
|
|
}
|
|
|
|
// Resolve relative to registry location
|
|
if !filepath.IsAbs(basePath) {
|
|
basePath = filepath.Join(registryDir, basePath)
|
|
}
|
|
|
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("target")), basePath)
|
|
|
|
// Parse type filter
|
|
var typeFilter []string
|
|
if only != "" {
|
|
for _, t := range strings.Split(only, ",") {
|
|
typeFilter = append(typeFilter, strings.TrimSpace(t))
|
|
}
|
|
fmt.Printf("%s %s\n", dimStyle.Render(i18n.Label("filter")), only)
|
|
}
|
|
|
|
// Ensure base path exists
|
|
if !dryRun {
|
|
if err := coreio.Local.EnsureDir(basePath); err != nil {
|
|
return fmt.Errorf("failed to create packages directory: %w", err)
|
|
}
|
|
}
|
|
|
|
// Get all available repos
|
|
allRepos := reg.List()
|
|
|
|
// Determine which repos to clone
|
|
var toClone []*repos.Repo
|
|
var skipped, exists int
|
|
|
|
// Use wizard in interactive mode, unless --all specified
|
|
useWizard := isTerminal() && !all && !dryRun
|
|
|
|
if useWizard {
|
|
selected, err := runPackageWizard(reg, typeFilter)
|
|
if err != nil {
|
|
return fmt.Errorf("wizard error: %w", err)
|
|
}
|
|
|
|
// Build set of selected repos
|
|
selectedSet := make(map[string]bool)
|
|
for _, name := range selected {
|
|
selectedSet[name] = true
|
|
}
|
|
|
|
// Filter repos based on selection
|
|
for _, repo := range allRepos {
|
|
if !selectedSet[repo.Name] {
|
|
skipped++
|
|
continue
|
|
}
|
|
|
|
// Check if already exists
|
|
repoPath := filepath.Join(basePath, repo.Name)
|
|
// Check .git dir existence via List
|
|
if _, err := coreio.Local.List(filepath.Join(repoPath, ".git")); err == nil {
|
|
exists++
|
|
continue
|
|
}
|
|
|
|
toClone = append(toClone, repo)
|
|
}
|
|
} else {
|
|
// Non-interactive: filter by type
|
|
typeFilterSet := make(map[string]bool)
|
|
for _, t := range typeFilter {
|
|
typeFilterSet[t] = true
|
|
}
|
|
|
|
for _, repo := range allRepos {
|
|
// Skip if type filter doesn't match (when filter is specified)
|
|
if len(typeFilterSet) > 0 && !typeFilterSet[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 := coreio.Local.List(filepath.Join(repoPath, ".git")); err == nil {
|
|
exists++
|
|
continue
|
|
}
|
|
|
|
toClone = append(toClone, repo)
|
|
}
|
|
}
|
|
|
|
// Summary
|
|
fmt.Println()
|
|
fmt.Printf("%s, %s, %s\n",
|
|
i18n.T("cmd.setup.to_clone", map[string]interface{}{"Count": len(toClone)}),
|
|
i18n.T("cmd.setup.exist", map[string]interface{}{"Count": exists}),
|
|
i18n.T("common.count.skipped", map[string]interface{}{"Count": skipped}))
|
|
|
|
if len(toClone) == 0 {
|
|
fmt.Printf("\n%s\n", i18n.T("cmd.setup.nothing_to_clone"))
|
|
return nil
|
|
}
|
|
|
|
if dryRun {
|
|
fmt.Printf("\n%s\n", i18n.T("cmd.setup.would_clone_list"))
|
|
for _, repo := range toClone {
|
|
fmt.Printf(" %s (%s)\n", repoNameStyle.Render(repo.Name), repo.Type)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Confirm in interactive mode
|
|
if useWizard {
|
|
confirmed, err := confirmClone(len(toClone), basePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !confirmed {
|
|
fmt.Println(i18n.T("cmd.setup.cancelled"))
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// Clone repos
|
|
fmt.Println()
|
|
var succeeded, failed int
|
|
|
|
for _, repo := range toClone {
|
|
fmt.Printf(" %s %s... ", dimStyle.Render(i18n.T("common.status.cloning")), repo.Name)
|
|
|
|
repoPath := filepath.Join(basePath, repo.Name)
|
|
|
|
err := gitClone(ctx, reg.Org, repo.Name, repoPath)
|
|
if err != nil {
|
|
fmt.Printf("%s\n", errorStyle.Render("x "+err.Error()))
|
|
failed++
|
|
} else {
|
|
fmt.Printf("%s\n", successStyle.Render(i18n.T("cmd.setup.done")))
|
|
succeeded++
|
|
}
|
|
}
|
|
|
|
// Summary
|
|
fmt.Println()
|
|
fmt.Printf("%s %s", successStyle.Render(i18n.Label("done")), i18n.T("cmd.setup.cloned_count", map[string]interface{}{"Count": succeeded}))
|
|
if failed > 0 {
|
|
fmt.Printf(", %s", errorStyle.Render(i18n.T("i18n.count.failed", failed)))
|
|
}
|
|
if exists > 0 {
|
|
fmt.Printf(", %s", i18n.T("cmd.setup.already_exist_count", map[string]interface{}{"Count": exists}))
|
|
}
|
|
fmt.Println()
|
|
|
|
// Run build if requested
|
|
if runBuild && succeeded > 0 {
|
|
fmt.Println()
|
|
fmt.Printf("%s %s\n", dimStyle.Render(">>"), i18n.ProgressSubject("run", "build"))
|
|
buildCmd := exec.Command("core", "build")
|
|
buildCmd.Dir = basePath
|
|
buildCmd.Stdout = os.Stdout
|
|
buildCmd.Stderr = os.Stderr
|
|
if err := buildCmd.Run(); err != nil {
|
|
return fmt.Errorf("%s: %w", i18n.T("i18n.fail.run", "build"), err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// gitClone clones a repository using gh CLI or git.
|
|
func gitClone(ctx context.Context, org, repo, path string) error {
|
|
// Try gh clone first with HTTPS (works without SSH keys)
|
|
if cli.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 {
|
|
return fmt.Errorf("%s", strings.TrimSpace(string(output)))
|
|
}
|
|
return nil
|
|
}
|