Files
gh-action-readme/internal/wizard/wizard.go
Ismo Vuorinen 7f80105ff5 feat: go 1.25.5, dependency updates, renamed internal/errors (#129)
* feat: rename internal/errors to internal/apperrors

* fix(tests): clear env values before using in tests

* feat: rename internal/errors to internal/apperrors

* chore(deps): update go and all dependencies

* chore: remove renovate from pre-commit, formatting

* chore: sonarcloud fixes

* feat: consolidate constants to appconstants/constants.go

* chore: sonarcloud fixes

* feat: simplification, deduplication, test utils

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: clean up

* fix: config discovery, const deduplication

* chore: fixes
2026-01-01 23:17:29 +02:00

372 lines
11 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Package wizard provides an interactive configuration wizard for gh-action-readme.
package wizard
import (
"bufio"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal"
"github.com/ivuorinen/gh-action-readme/internal/git"
"github.com/ivuorinen/gh-action-readme/internal/helpers"
)
// ConfigWizard handles interactive configuration setup.
type ConfigWizard struct {
output *internal.ColoredOutput
scanner *bufio.Scanner
config *internal.AppConfig
repoInfo *git.RepoInfo
actionDir string
}
// NewConfigWizard creates a new configuration wizard instance.
func NewConfigWizard(output *internal.ColoredOutput) *ConfigWizard {
return &ConfigWizard{
output: output,
scanner: bufio.NewScanner(os.Stdin),
config: internal.DefaultAppConfig(),
}
}
// Run executes the interactive configuration wizard.
func (w *ConfigWizard) Run() (*internal.AppConfig, error) {
w.output.Bold("🧙 Welcome to gh-action-readme Configuration Wizard!")
w.output.Info("This wizard will help you set up your configuration step by step.\n")
// Step 1: Auto-detect project settings
if err := w.detectProjectSettings(); err != nil {
w.output.Warning("Could not auto-detect project settings: %v", err)
}
// Step 2: Configure basic settings
w.configureBasicSettings()
// Step 3: Configure template and output settings
w.configureTemplateSettings()
// Step 4: Configure features
w.configureFeatures()
// Step 5: Configure GitHub integration
w.configureGitHubIntegration()
// Step 6: Summary and confirmation
if err := w.showSummaryAndConfirm(); err != nil {
return nil, fmt.Errorf("configuration canceled: %w", err)
}
w.output.Success("\n✅ Configuration completed successfully!")
return w.config, nil
}
// detectProjectSettings auto-detects project settings from the current environment.
func (w *ConfigWizard) detectProjectSettings() error {
w.output.Bold("🔍 Step 1: Auto-detecting project settings...")
// Detect current directory
currentDir, err := helpers.GetCurrentDir()
if err != nil {
return fmt.Errorf(appconstants.ErrFailedToGetCurrentDir, err)
}
w.actionDir = currentDir
// Detect git repository
repoRoot := helpers.FindGitRepoRoot(currentDir)
if repoRoot != "" {
repoInfo, err := git.DetectRepository(repoRoot)
if err == nil {
w.repoInfo = repoInfo
w.config.Organization = repoInfo.Organization
w.config.Repository = repoInfo.Repository
w.output.Success(" 📁 Repository: %s/%s", w.config.Organization, w.config.Repository)
}
}
// Check for existing action files
actionFiles := w.findActionFiles(currentDir)
if len(actionFiles) > 0 {
w.output.Success(" 🎯 Found %d action file(s)", len(actionFiles))
}
return nil
}
// configureBasicSettings handles basic configuration prompts.
func (w *ConfigWizard) configureBasicSettings() {
w.output.Bold("\n⚙ Step 2: Basic Settings")
// Organization
w.config.Organization = w.promptWithDefault("Organization/Owner", w.config.Organization)
// Repository
w.config.Repository = w.promptWithDefault("Repository Name", w.config.Repository)
// Version (optional)
version := w.promptWithDefault("Version (optional)", "")
if version != "" {
w.config.Version = version
}
}
// configureTemplateSettings handles template and output configuration.
func (w *ConfigWizard) configureTemplateSettings() {
w.output.Bold("\n🎨 Step 3: Template & Output Settings")
w.configureThemeSelection()
w.configureOutputFormat()
w.configureOutputDirectory()
}
// configureThemeSelection handles theme selection.
func (w *ConfigWizard) configureThemeSelection() {
w.output.Info("Available themes:")
themes := w.getAvailableThemes()
w.displayThemeOptions(themes)
themeChoice := w.promptWithDefault("Choose theme (1-5)", "1")
if choice, err := strconv.Atoi(themeChoice); err == nil && choice >= 1 && choice <= len(themes) {
w.config.Theme = themes[choice-1].name
}
}
// configureOutputFormat handles output format selection.
func (w *ConfigWizard) configureOutputFormat() {
w.output.Info("\nAvailable output formats:")
formats := []string{"md", "html", "json", "asciidoc"}
w.displayFormatOptions(formats)
formatChoice := w.promptWithDefault("Choose output format (1-4)", "1")
if choice, err := strconv.Atoi(formatChoice); err == nil && choice >= 1 && choice <= len(formats) {
w.config.OutputFormat = formats[choice-1]
}
}
// configureOutputDirectory handles output directory configuration.
func (w *ConfigWizard) configureOutputDirectory() {
w.config.OutputDir = w.promptWithDefault("Output directory", w.config.OutputDir)
}
// getAvailableThemes returns the list of available themes.
func (w *ConfigWizard) getAvailableThemes() []struct {
name string
desc string
} {
return []struct {
name string
desc string
}{
{"default", "Original simple template"},
{"github", "GitHub-style with badges and collapsible sections"},
{"gitlab", "GitLab-focused with CI/CD examples"},
{"minimal", "Clean and concise documentation"},
{"professional", "Comprehensive with troubleshooting and ToC"},
}
}
// displayThemeOptions displays the theme options with current selection.
func (w *ConfigWizard) displayThemeOptions(themes []struct {
name string
desc string
}) {
for i, theme := range themes {
marker := " "
if theme.name == w.config.Theme {
marker = appconstants.SymbolArrow
}
w.output.Printf(" %s %d. %s - %s", marker, i+1, theme.name, theme.desc)
}
}
// displayFormatOptions displays the output format options with current selection.
func (w *ConfigWizard) displayFormatOptions(formats []string) {
for i, format := range formats {
marker := " "
if format == w.config.OutputFormat {
marker = appconstants.SymbolArrow
}
w.output.Printf(" %s %d. %s", marker, i+1, format)
}
}
// configureFeatures handles feature configuration.
func (w *ConfigWizard) configureFeatures() {
w.output.Bold("\n🚀 Step 4: Features")
// Dependency analysis
w.output.Info("Dependency analysis provides detailed information about GitHub Action dependencies.")
analyzeDeps := w.promptYesNo("Enable dependency analysis?", w.config.AnalyzeDependencies)
w.config.AnalyzeDependencies = analyzeDeps
// Security information
w.output.Info("Security information shows pinned vs floating versions and security recommendations.")
showSecurity := w.promptYesNo("Show security information?", w.config.ShowSecurityInfo)
w.config.ShowSecurityInfo = showSecurity
}
// configureGitHubIntegration handles GitHub API configuration.
func (w *ConfigWizard) configureGitHubIntegration() {
w.output.Bold("\n🐙 Step 5: GitHub Integration")
// Check for existing token
existingToken := internal.GetGitHubToken(w.config)
if existingToken != "" {
w.output.Success("GitHub token already configured ✓")
return
}
w.output.Info("GitHub integration requires a personal access token for:")
w.output.Printf(" • Enhanced dependency analysis")
w.output.Printf(" • Latest version checking")
w.output.Printf(" • Repository information")
w.output.Printf(" • Rate limit improvements")
setupToken := w.promptYesNo("Set up GitHub token now?", false)
if !setupToken {
w.output.Info("You can set up the token later using environment variables:")
w.output.Printf(" export GITHUB_TOKEN=your_personal_access_token")
return
}
w.output.Info("\nTo create a personal access token:")
w.output.Printf(" 1. Visit: https://github.com/settings/tokens")
w.output.Printf(" 2. Click 'Generate new token (classic)'")
w.output.Printf(" 3. Select scopes: 'repo' (for private repos) or 'public_repo' (for public only)")
w.output.Printf(" 4. Copy the generated token")
token := w.promptSensitive("Enter your GitHub token (or press Enter to skip)")
if token != "" {
// Validate token format (basic check)
hasPersonalPrefix := strings.HasPrefix(token, appconstants.TokenPrefixGitHubPersonal)
hasPATPrefix := strings.HasPrefix(token, appconstants.TokenPrefixGitHubPAT)
if hasPersonalPrefix || hasPATPrefix {
w.config.GitHubToken = token
w.output.Success("GitHub token configured ✓")
} else {
w.output.Warning("Token format looks unusual. You can update it later if needed.")
w.config.GitHubToken = token
}
}
}
// showSummaryAndConfirm displays configuration summary and asks for confirmation.
func (w *ConfigWizard) showSummaryAndConfirm() error {
w.output.Bold("\n📋 Step 6: Configuration Summary")
w.output.Info("Your configuration:")
w.output.Printf(" Repository: %s/%s", w.config.Organization, w.config.Repository)
if w.config.Version != "" {
w.output.Printf(" Version: %s", w.config.Version)
}
w.output.Printf(" Theme: %s", w.config.Theme)
w.output.Printf(" Output Format: %s", w.config.OutputFormat)
w.output.Printf(" Output Directory: %s", w.config.OutputDir)
w.output.Printf(" Dependency Analysis: %t", w.config.AnalyzeDependencies)
w.output.Printf(" Security Information: %t", w.config.ShowSecurityInfo)
tokenStatus := "Not configured"
if w.config.GitHubToken != "" {
tokenStatus = "Configured ✓" // #nosec G101 -- status message, not actual token
} else if internal.GetGitHubToken(w.config) != "" {
tokenStatus = "Configured via environment ✓" // #nosec G101 -- status message, not actual token
}
w.output.Printf(" GitHub Token: %s", tokenStatus)
return w.confirmConfiguration()
}
// confirmConfiguration asks user to confirm the configuration.
func (w *ConfigWizard) confirmConfiguration() error {
w.output.Info("")
confirmed := w.promptYesNo("Save this configuration?", true)
if !confirmed {
return errors.New("configuration canceled by user")
}
return nil
}
// promptWithDefault prompts for input with a default value.
func (w *ConfigWizard) promptWithDefault(prompt, defaultValue string) string {
if defaultValue != "" {
w.output.Printf(appconstants.FormatPromptDefault, prompt, defaultValue)
} else {
w.output.Printf(appconstants.FormatPrompt, prompt)
}
if w.scanner.Scan() {
input := strings.TrimSpace(w.scanner.Text())
if input == "" {
return defaultValue
}
return input
}
return defaultValue
}
// promptSensitive prompts for sensitive input (like tokens) without echoing.
func (w *ConfigWizard) promptSensitive(prompt string) string {
w.output.Printf(appconstants.FormatPrompt, prompt)
if w.scanner.Scan() {
return strings.TrimSpace(w.scanner.Text())
}
return ""
}
// promptYesNo prompts for a yes/no answer.
func (w *ConfigWizard) promptYesNo(prompt string, defaultValue bool) bool {
defaultStr := "y/N"
if defaultValue {
defaultStr = "Y/n"
}
w.output.Printf(appconstants.FormatPromptDefault, prompt, defaultStr)
if w.scanner.Scan() {
input := strings.ToLower(strings.TrimSpace(w.scanner.Text()))
switch input {
case "y", appconstants.InputYes:
return true
case "n", "no":
return false
case "":
return defaultValue
default:
w.output.Warning("Please answer 'y' or 'n'. Using default.")
return defaultValue
}
}
return defaultValue
}
// findActionFiles discovers action files in the given directory.
func (w *ConfigWizard) findActionFiles(dir string) []string {
var actionFiles []string
// Check for action.yml and action.yaml
for _, filename := range []string{"action.yml", "action.yaml"} {
actionPath := filepath.Join(dir, filename)
if _, err := os.Stat(actionPath); err == nil {
actionFiles = append(actionFiles, actionPath)
}
}
return actionFiles
}