go/cmd/setup/setup_wizard.go
Snider 9565122fdc feat(cli): implement setup wizard, fix ai examples, ci dry-run flag
Setup command now has three modes:
- Registry mode: interactive wizard to select packages
- Bootstrap mode: clones core-devops first, then wizard
- Repo setup mode: generates .core/{build,release,test}.yaml

Changes:
- setup: add interactive package selection with charmbracelet/huh
- setup: detect project type (go/php/node/wails) and generate configs
- setup: auto-detect GitHub repo from git remote
- ai: fix command examples (core dev -> core ai)
- ci: rename flag to --we-are-go-for-launch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 19:09:51 +00:00

221 lines
5.2 KiB
Go

// setup_wizard.go implements the interactive package selection wizard.
//
// Uses charmbracelet/huh for a rich terminal UI with multi-select checkboxes.
// Falls back to non-interactive mode when not in a TTY or --all is specified.
package setup
import (
"fmt"
"os"
"sort"
"strings"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
"github.com/host-uk/core/pkg/repos"
"golang.org/x/term"
)
// wizardTheme returns a Dracula-inspired theme matching our CLI styling.
func wizardTheme() *huh.Theme {
t := huh.ThemeDracula()
return t
}
// isTerminal returns true if stdin is a terminal.
func isTerminal() bool {
return term.IsTerminal(int(os.Stdin.Fd()))
}
// promptSetupChoice asks the user whether to setup the working directory or create a package.
func promptSetupChoice() (string, error) {
var choice string
form := huh.NewForm(
huh.NewGroup(
huh.NewSelect[string]().
Title("This directory is a git repository").
Description("What would you like to do?").
Options(
huh.NewOption("Setup Working Directory", "setup").Selected(true),
huh.NewOption("Create Package (clone repos into subdirectory)", "package"),
).
Value(&choice),
),
).WithTheme(wizardTheme())
if err := form.Run(); err != nil {
return "", err
}
return choice, nil
}
// promptProjectName asks the user for a project directory name.
func promptProjectName(defaultName string) (string, error) {
var name string
form := huh.NewForm(
huh.NewGroup(
huh.NewInput().
Title("Project directory name").
Description("Enter the name for your new workspace directory").
Placeholder(defaultName).
Value(&name),
),
).WithTheme(wizardTheme())
if err := form.Run(); err != nil {
return "", err
}
if name == "" {
return defaultName, nil
}
return name, nil
}
// groupPackagesByType organizes repos by their type for display.
func groupPackagesByType(reposList []*repos.Repo) map[string][]*repos.Repo {
groups := make(map[string][]*repos.Repo)
for _, repo := range reposList {
repoType := repo.Type
if repoType == "" {
repoType = "other"
}
groups[repoType] = append(groups[repoType], repo)
}
// Sort within each group
for _, group := range groups {
sort.Slice(group, func(i, j int) bool {
return group[i].Name < group[j].Name
})
}
return groups
}
// packageOption represents a selectable package in the wizard.
type packageOption struct {
repo *repos.Repo
selected bool
}
// runPackageWizard presents an interactive multi-select UI for package selection.
// Returns the list of selected repo names.
func runPackageWizard(reg *repos.Registry, preselectedTypes []string) ([]string, error) {
allRepos := reg.List()
// Build preselection set
preselect := make(map[string]bool)
for _, t := range preselectedTypes {
preselect[strings.TrimSpace(t)] = true
}
// Group repos by type for organized display
groups := groupPackagesByType(allRepos)
// Build options with preselection
var options []huh.Option[string]
typeOrder := []string{"foundation", "module", "product", "template", "other"}
for _, typeKey := range typeOrder {
group, ok := groups[typeKey]
if !ok || len(group) == 0 {
continue
}
// Add type header as a visual separator (empty option)
typeLabel := strings.ToUpper(typeKey)
options = append(options, huh.NewOption[string](
fmt.Sprintf("── %s ──", typeLabel),
"",
).Selected(false))
for _, repo := range group {
// Skip if clone: false
if repo.Clone != nil && !*repo.Clone {
continue
}
label := repo.Name
if repo.Description != "" {
label = fmt.Sprintf("%s - %s", repo.Name, truncateDesc(repo.Description, 40))
}
// Preselect based on type filter or select all if no filter
selected := len(preselect) == 0 || preselect[repo.Type]
options = append(options, huh.NewOption[string](label, repo.Name).Selected(selected))
}
}
var selected []string
// Header styling
headerStyle := lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#3b82f6")).
MarginBottom(1)
fmt.Println(headerStyle.Render("Package Selection"))
fmt.Println("Use space to select/deselect, enter to confirm")
fmt.Println()
form := huh.NewForm(
huh.NewGroup(
huh.NewMultiSelect[string]().
Title("Select packages to clone").
Options(options...).
Value(&selected).
Filterable(true).
Height(20),
),
).WithTheme(wizardTheme())
if err := form.Run(); err != nil {
return nil, err
}
// Filter out empty values (type headers)
var result []string
for _, name := range selected {
if name != "" {
result = append(result, name)
}
}
return result, nil
}
// confirmClone asks for confirmation before cloning.
func confirmClone(count int, target string) (bool, error) {
var confirmed bool
form := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(fmt.Sprintf("Clone %d packages to %s?", count, target)).
Affirmative("Yes, clone").
Negative("Cancel").
Value(&confirmed),
),
).WithTheme(wizardTheme())
if err := form.Run(); err != nil {
return false, err
}
return confirmed, nil
}
// truncateDesc truncates a description to max length with ellipsis.
func truncateDesc(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max-3] + "..."
}