diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 823be6f..4ee3f71 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -99,20 +99,6 @@ type Provider interface { Load(storage Storage) (*Credential, error) } -// Prompter abstracts user interaction for credential collection. -// -//go:generate go run github.com/matryer/moq@latest -pkg mocks -out mocks/prompter.go . Prompter -type Prompter interface { - // Print outputs text to the user. - Print(message string) - - // PromptSecret prompts for secret input (no echo). - PromptSecret(prompt string) (string, error) - - // PromptChoice prompts user to select from options, returns 0-based index. - PromptChoice(prompt string, options []string) (int, error) -} - // StoreCredential is a helper function to store a credential in JSON format. func StoreCredential(storage Storage, account string, cred Credential) error { data, err := json.Marshal(cred) diff --git a/internal/auth/mocks/prompter.go b/internal/auth/mocks/prompter.go deleted file mode 100644 index bebe4ad..0000000 --- a/internal/auth/mocks/prompter.go +++ /dev/null @@ -1,170 +0,0 @@ -// Code generated by moq; DO NOT EDIT. -// github.com/matryer/moq - -package mocks - -import ( - "sync" - - "github.com/jmgilman/headjack/internal/auth" -) - -// Ensure, that PrompterMock does implement auth.Prompter. -// If this is not the case, regenerate this file with moq. -var _ auth.Prompter = &PrompterMock{} - -// PrompterMock is a mock implementation of auth.Prompter. -// -// func TestSomethingThatUsesPrompter(t *testing.T) { -// -// // make and configure a mocked auth.Prompter -// mockedPrompter := &PrompterMock{ -// PrintFunc: func(message string) { -// panic("mock out the Print method") -// }, -// PromptChoiceFunc: func(prompt string, options []string) (int, error) { -// panic("mock out the PromptChoice method") -// }, -// PromptSecretFunc: func(prompt string) (string, error) { -// panic("mock out the PromptSecret method") -// }, -// } -// -// // use mockedPrompter in code that requires auth.Prompter -// // and then make assertions. -// -// } -type PrompterMock struct { - // PrintFunc mocks the Print method. - PrintFunc func(message string) - - // PromptChoiceFunc mocks the PromptChoice method. - PromptChoiceFunc func(prompt string, options []string) (int, error) - - // PromptSecretFunc mocks the PromptSecret method. - PromptSecretFunc func(prompt string) (string, error) - - // calls tracks calls to the methods. - calls struct { - // Print holds details about calls to the Print method. - Print []struct { - // Message is the message argument value. - Message string - } - // PromptChoice holds details about calls to the PromptChoice method. - PromptChoice []struct { - // Prompt is the prompt argument value. - Prompt string - // Options is the options argument value. - Options []string - } - // PromptSecret holds details about calls to the PromptSecret method. - PromptSecret []struct { - // Prompt is the prompt argument value. - Prompt string - } - } - lockPrint sync.RWMutex - lockPromptChoice sync.RWMutex - lockPromptSecret sync.RWMutex -} - -// Print calls PrintFunc. -func (mock *PrompterMock) Print(message string) { - if mock.PrintFunc == nil { - panic("PrompterMock.PrintFunc: method is nil but Prompter.Print was just called") - } - callInfo := struct { - Message string - }{ - Message: message, - } - mock.lockPrint.Lock() - mock.calls.Print = append(mock.calls.Print, callInfo) - mock.lockPrint.Unlock() - mock.PrintFunc(message) -} - -// PrintCalls gets all the calls that were made to Print. -// Check the length with: -// -// len(mockedPrompter.PrintCalls()) -func (mock *PrompterMock) PrintCalls() []struct { - Message string -} { - var calls []struct { - Message string - } - mock.lockPrint.RLock() - calls = mock.calls.Print - mock.lockPrint.RUnlock() - return calls -} - -// PromptChoice calls PromptChoiceFunc. -func (mock *PrompterMock) PromptChoice(prompt string, options []string) (int, error) { - if mock.PromptChoiceFunc == nil { - panic("PrompterMock.PromptChoiceFunc: method is nil but Prompter.PromptChoice was just called") - } - callInfo := struct { - Prompt string - Options []string - }{ - Prompt: prompt, - Options: options, - } - mock.lockPromptChoice.Lock() - mock.calls.PromptChoice = append(mock.calls.PromptChoice, callInfo) - mock.lockPromptChoice.Unlock() - return mock.PromptChoiceFunc(prompt, options) -} - -// PromptChoiceCalls gets all the calls that were made to PromptChoice. -// Check the length with: -// -// len(mockedPrompter.PromptChoiceCalls()) -func (mock *PrompterMock) PromptChoiceCalls() []struct { - Prompt string - Options []string -} { - var calls []struct { - Prompt string - Options []string - } - mock.lockPromptChoice.RLock() - calls = mock.calls.PromptChoice - mock.lockPromptChoice.RUnlock() - return calls -} - -// PromptSecret calls PromptSecretFunc. -func (mock *PrompterMock) PromptSecret(prompt string) (string, error) { - if mock.PromptSecretFunc == nil { - panic("PrompterMock.PromptSecretFunc: method is nil but Prompter.PromptSecret was just called") - } - callInfo := struct { - Prompt string - }{ - Prompt: prompt, - } - mock.lockPromptSecret.Lock() - mock.calls.PromptSecret = append(mock.calls.PromptSecret, callInfo) - mock.lockPromptSecret.Unlock() - return mock.PromptSecretFunc(prompt) -} - -// PromptSecretCalls gets all the calls that were made to PromptSecret. -// Check the length with: -// -// len(mockedPrompter.PromptSecretCalls()) -func (mock *PrompterMock) PromptSecretCalls() []struct { - Prompt string -} { - var calls []struct { - Prompt string - } - mock.lockPromptSecret.RLock() - calls = mock.calls.PromptSecret - mock.lockPromptSecret.RUnlock() - return calls -} diff --git a/internal/auth/prompter.go b/internal/auth/prompter.go deleted file mode 100644 index 5006c54..0000000 --- a/internal/auth/prompter.go +++ /dev/null @@ -1,72 +0,0 @@ -package auth - -import ( - "errors" - "fmt" - "strings" - - "github.com/charmbracelet/huh" -) - -// HuhPrompter implements Prompter using charmbracelet/huh for interactive forms. -type HuhPrompter struct{} - -// NewTerminalPrompter creates a new HuhPrompter for interactive terminal prompts. -func NewTerminalPrompter() *HuhPrompter { - return &HuhPrompter{} -} - -// Print outputs text to the user. -func (p *HuhPrompter) Print(message string) { - fmt.Println(message) -} - -// PromptSecret prompts for secret input with masked display. -func (p *HuhPrompter) PromptSecret(prompt string) (string, error) { - var value string - - err := huh.NewInput(). - Title(prompt). - EchoMode(huh.EchoModePassword). - Value(&value). - Run() - - if err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return "", errors.New("canceled by user") - } - return "", fmt.Errorf("prompt input: %w", err) - } - - return strings.TrimSpace(value), nil -} - -// PromptChoice prompts user to select from options and returns the 0-based index. -func (p *HuhPrompter) PromptChoice(prompt string, options []string) (int, error) { - if len(options) == 0 { - return 0, errors.New("no options provided") - } - - // Build huh options with display labels and index values - huhOptions := make([]huh.Option[int], len(options)) - for i, opt := range options { - huhOptions[i] = huh.NewOption(opt, i) - } - - var selected int - - err := huh.NewSelect[int](). - Title(prompt). - Options(huhOptions...). - Value(&selected). - Run() - - if err != nil { - if errors.Is(err, huh.ErrUserAborted) { - return 0, errors.New("canceled by user") - } - return 0, fmt.Errorf("prompt choice: %w", err) - } - - return selected, nil -} diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index ab12c77..1c259e9 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -8,6 +8,7 @@ import ( "github.com/jmgilman/headjack/internal/auth" "github.com/jmgilman/headjack/internal/keychain" + "github.com/jmgilman/headjack/internal/prompt" ) var authCmd = &cobra.Command{ @@ -128,13 +129,13 @@ func runAuthFlow(provider auth.Provider) error { return fmt.Errorf("initialize credential storage: %w", err) } - prompter := auth.NewTerminalPrompter() + prompter := prompt.New() info := provider.Info() prompter.Print(fmt.Sprintf("Configure %s authentication", info.Name)) prompter.Print("") - choice, err := prompter.PromptChoice("Authentication method:", []string{ + choice, err := prompter.Choice("Authentication method:", []string{ "Subscription", "API Key", }) @@ -169,7 +170,7 @@ func runAuthFlow(provider auth.Provider) error { // handleSubscriptionAuth handles subscription-based authentication. // For Claude, prompts for manual token entry. // For Gemini/Codex, attempts to read existing credentials from config files. -func handleSubscriptionAuth(provider auth.Provider, prompter auth.Prompter) (auth.Credential, error) { +func handleSubscriptionAuth(provider auth.Provider, prompter prompt.Prompter) (auth.Credential, error) { // Try to auto-detect existing credentials value, err := provider.CheckSubscription() if err == nil { @@ -188,7 +189,7 @@ func handleSubscriptionAuth(provider auth.Provider, prompter auth.Prompter) (aut prompter.Print(err.Error()) prompter.Print("") - value, err = prompter.PromptSecret("Paste your credential: ") + value, err = prompter.Secret("Paste your credential: ") if err != nil { return auth.Credential{}, fmt.Errorf("read credential: %w", err) } @@ -204,13 +205,13 @@ func handleSubscriptionAuth(provider auth.Provider, prompter auth.Prompter) (aut } // handleAPIKeyAuth handles API key authentication. -func handleAPIKeyAuth(provider auth.Provider, prompter auth.Prompter) (auth.Credential, error) { +func handleAPIKeyAuth(provider auth.Provider, prompter prompt.Prompter) (auth.Credential, error) { info := provider.Info() prompter.Print(fmt.Sprintf("Enter your %s API key.", info.Name)) prompter.Print("") - value, err := prompter.PromptSecret("API key: ") + value, err := prompter.Secret("API key: ") if err != nil { return auth.Credential{}, fmt.Errorf("read API key: %w", err) } diff --git a/internal/cmd/run.go b/internal/cmd/run.go index 1e0862c..7370d1f 100644 --- a/internal/cmd/run.go +++ b/internal/cmd/run.go @@ -3,13 +3,13 @@ package cmd import ( "errors" "fmt" - "os/exec" "github.com/spf13/cobra" "github.com/jmgilman/headjack/internal/container" "github.com/jmgilman/headjack/internal/devcontainer" "github.com/jmgilman/headjack/internal/instance" + "github.com/jmgilman/headjack/internal/prompt" ) var runCmd = &cobra.Command{ @@ -131,14 +131,11 @@ func getOrCreateInstance(cmd *cobra.Command, mgr *instance.Manager, repoPath, br return inst, nil } -// devcontainerCLI is the name of the devcontainer CLI binary. -const devcontainerCLI = "devcontainer" - // buildCreateConfig builds the instance creation config, detecting devcontainer mode if applicable. // Devcontainer mode is used when: // - No --image flag was explicitly passed (imageExplicit is false) // - A devcontainer.json exists in the repo -// - The devcontainer CLI is available +// - The devcontainer CLI is available (or can be installed) // // Returns an error if no devcontainer.json is found and no image is configured. func buildCreateConfig(cmd *cobra.Command, repoPath, branch, image string, imageExplicit bool) (instance.CreateConfig, error) { @@ -147,14 +144,6 @@ func buildCreateConfig(cmd *cobra.Command, repoPath, branch, image string, image Image: image, } - // Always check if devcontainer CLI is available and warn if not - devcontainerAvailable := isDevcontainerCLIAvailable() - if !devcontainerAvailable { - fmt.Println("Warning: devcontainer CLI not found in PATH") - fmt.Println(" Install with: npm install -g @devcontainers/cli") - fmt.Println(" See: https://github.com/devcontainers/cli") - } - // If image was explicitly passed, use vanilla mode if imageExplicit { return cfg, nil @@ -164,9 +153,21 @@ func buildCreateConfig(cmd *cobra.Command, repoPath, branch, image string, image hasDevcontainer := devcontainer.HasConfig(repoPath) if hasDevcontainer { - if !devcontainerAvailable { - // Devcontainer exists but CLI not available - error - return cfg, errors.New("devcontainer.json found but devcontainer CLI is not installed") + // Resolve devcontainer CLI (may prompt for installation) + mgr := ManagerFromContext(cmd.Context()) + if mgr == nil { + return cfg, errors.New("manager not available") + } + + loader := LoaderFromContext(cmd.Context()) + if loader == nil { + return cfg, errors.New("config loader not available") + } + + resolver := devcontainer.NewCLIResolver(loader, prompt.New(), mgr.Executor()) + cliPath, err := resolver.Resolve(cmd.Context()) + if err != nil { + return cfg, err } // Create devcontainer runtime wrapping the underlying runtime @@ -174,7 +175,7 @@ func buildCreateConfig(cmd *cobra.Command, repoPath, branch, image string, image if appCfg := ConfigFromContext(cmd.Context()); appCfg != nil && appCfg.Runtime.Name != "" { runtimeName = appCfg.Runtime.Name } - dcRuntime := createDevcontainerRuntime(cmd, runtimeName) + dcRuntime := createDevcontainerRuntime(cmd, runtimeName, cliPath) if dcRuntime == nil { return cfg, errors.New("failed to create devcontainer runtime") } @@ -196,14 +197,8 @@ func buildCreateConfig(cmd *cobra.Command, repoPath, branch, image string, image return cfg, nil } -// isDevcontainerCLIAvailable checks if the devcontainer CLI is in PATH. -func isDevcontainerCLIAvailable() bool { - _, err := exec.LookPath(devcontainerCLI) - return err == nil -} - // createDevcontainerRuntime creates a DevcontainerRuntime wrapping the appropriate underlying runtime. -func createDevcontainerRuntime(cmd *cobra.Command, runtimeName string) container.Runtime { +func createDevcontainerRuntime(cmd *cobra.Command, runtimeName, cliPath string) container.Runtime { // Get the underlying runtime from the manager mgr := ManagerFromContext(cmd.Context()) if mgr == nil { @@ -224,7 +219,7 @@ func createDevcontainerRuntime(cmd *cobra.Command, runtimeName string) container return devcontainer.NewRuntime( mgr.Runtime(), mgr.Executor(), - "devcontainer", // CLI path - assumes it's in PATH + cliPath, dockerPath, ) } diff --git a/internal/config/config.go b/internal/config/config.go index 36d09a0..d792f4e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -54,10 +54,11 @@ var validate = validator.New() // Config represents the full Headjack configuration. type Config struct { - Default DefaultConfig `mapstructure:"default" validate:"required"` - Agents map[string]AgentConfig `mapstructure:"agents" validate:"dive,keys,oneof=claude gemini codex,endkeys"` - Storage StorageConfig `mapstructure:"storage" validate:"required"` - Runtime RuntimeConfig `mapstructure:"runtime"` + Default DefaultConfig `mapstructure:"default" validate:"required"` + Agents map[string]AgentConfig `mapstructure:"agents" validate:"dive,keys,oneof=claude gemini codex,endkeys"` + Storage StorageConfig `mapstructure:"storage" validate:"required"` + Runtime RuntimeConfig `mapstructure:"runtime"` + Devcontainer DevcontainerConfig `mapstructure:"devcontainer"` } // DefaultConfig holds default values for new instances. @@ -84,6 +85,11 @@ type RuntimeConfig struct { Flags map[string]any `mapstructure:"flags"` } +// DevcontainerConfig holds devcontainer CLI configuration. +type DevcontainerConfig struct { + Path string `mapstructure:"path"` +} + // Validate checks the configuration for errors using struct tags. func (c *Config) Validate() error { if err := validate.Struct(c); err != nil { @@ -150,6 +156,7 @@ func (l *Loader) setDefaults() { l.v.SetDefault("agents.codex.env", map[string]string{}) l.v.SetDefault("runtime.name", "docker") l.v.SetDefault("runtime.flags", map[string]any{}) + l.v.SetDefault("devcontainer.path", "") } // Load reads the configuration file, creating defaults if it doesn't exist. @@ -175,6 +182,7 @@ func (l *Loader) Load() (*Config, error) { cfg.Storage.Worktrees = l.expandPath(cfg.Storage.Worktrees) cfg.Storage.Catalog = l.expandPath(cfg.Storage.Catalog) cfg.Storage.Logs = l.expandPath(cfg.Storage.Logs) + cfg.Devcontainer.Path = l.expandPath(cfg.Devcontainer.Path) return &cfg, nil } diff --git a/internal/devcontainer/cli.go b/internal/devcontainer/cli.go new file mode 100644 index 0000000..43d16c0 --- /dev/null +++ b/internal/devcontainer/cli.go @@ -0,0 +1,208 @@ +package devcontainer + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/jmgilman/headjack/internal/config" + "github.com/jmgilman/headjack/internal/exec" + "github.com/jmgilman/headjack/internal/prompt" +) + +const ( + // devcontainerBin is the name of the devcontainer CLI binary. + devcontainerBin = "devcontainer" + + // npmBin is the name of the npm binary. + npmBin = "npm" + + // devcontainerPackage is the npm package name for the devcontainer CLI. + devcontainerPackage = "@devcontainers/cli" + + // cliInstallDir is the subdirectory under XDG data dir for CLI installation. + cliInstallDir = "devcontainer-cli" +) + +// CLIResolver resolves the path to the devcontainer CLI, offering to install +// it locally if not found in PATH or config. +type CLIResolver struct { + loader *config.Loader + prompter prompt.Prompter + executor exec.Executor + homeDir string +} + +// NewCLIResolver creates a new CLIResolver. +func NewCLIResolver(loader *config.Loader, prompter prompt.Prompter, executor exec.Executor) *CLIResolver { + homeDir, err := os.UserHomeDir() + if err != nil { + // Fall back to empty string; installDir will fail gracefully + homeDir = "" + } + return &CLIResolver{ + loader: loader, + prompter: prompter, + executor: executor, + homeDir: homeDir, + } +} + +// Resolve returns the path to the devcontainer CLI binary. +// It checks in order: +// 1. devcontainer in PATH +// 2. devcontainer.path from config (if set and binary exists) +// 3. Offers to install via npm if available +// +// Returns an error if the CLI cannot be found or installed. +func (r *CLIResolver) Resolve(ctx context.Context) (string, error) { + // 1. Check if devcontainer is in PATH + if path, err := r.executor.LookPath(devcontainerBin); err == nil { + return path, nil + } + + // 2. Check if devcontainer.path is set in config + cfg, err := r.loader.Load() + if err != nil { + return "", fmt.Errorf("load config: %w", err) + } + + if cfg.Devcontainer.Path != "" { + if _, statErr := os.Stat(cfg.Devcontainer.Path); statErr == nil { + return cfg.Devcontainer.Path, nil + } + // Path is set but binary doesn't exist - fall through to install + } + + // 3. Check if npm is available + if _, lookErr := r.executor.LookPath(npmBin); lookErr != nil { + return "", r.noNpmError() + } + + // 4. Prompt user to install + confirmed, err := r.prompter.Confirm( + "Install devcontainer CLI?", + "The devcontainer CLI is required to use devcontainer.json configurations.\nWould you like to install it locally via npm?", + ) + if err != nil { + return "", fmt.Errorf("prompt: %w", err) + } + + if !confirmed { + return "", r.declinedError() + } + + // 5. Install the CLI + path, err := r.install(ctx) + if err != nil { + return "", err + } + + // 6. Save path to config + if err := r.loader.Set("devcontainer.path", path); err != nil { + // Non-fatal: CLI is installed but config wasn't saved + r.prompter.Print(fmt.Sprintf("Warning: could not save config: %v", err)) + } + + return path, nil +} + +// install installs the devcontainer CLI via npm and returns the path to the binary. +func (r *CLIResolver) install(ctx context.Context) (string, error) { + installDir := r.installDir() + binPath := filepath.Join(installDir, "node_modules", ".bin", devcontainerBin) + + // Track success for cleanup + success := false + defer func() { + if !success { + _ = os.RemoveAll(installDir) + } + }() + + // Create install directory + if err := os.MkdirAll(installDir, 0o750); err != nil { + return "", fmt.Errorf("create install directory: %w", err) + } + + r.prompter.Print("Installing devcontainer CLI...") + + // Run npm install + result, err := r.executor.Run(ctx, &exec.RunOptions{ + Name: npmBin, + Args: []string{"install", "--prefix", installDir, devcontainerPackage}, + }) + if err != nil { + stderr := "" + if result != nil { + stderr = strings.TrimSpace(string(result.Stderr)) + } + if stderr != "" { + return "", fmt.Errorf("npm install failed: %s", stderr) + } + return "", fmt.Errorf("npm install failed: %w", err) + } + + // Validate the installation + if err := r.validate(ctx, binPath); err != nil { + return "", fmt.Errorf("validate installation: %w", err) + } + + r.prompter.Print("devcontainer CLI installed successfully.") + + success = true + return binPath, nil +} + +// validate verifies that the devcontainer CLI is working by running --version. +func (r *CLIResolver) validate(ctx context.Context, cliPath string) error { + result, err := r.executor.Run(ctx, &exec.RunOptions{ + Name: cliPath, + Args: []string{"--version"}, + }) + if err != nil { + stderr := "" + if result != nil { + stderr = strings.TrimSpace(string(result.Stderr)) + } + if stderr != "" { + return fmt.Errorf("devcontainer --version failed: %s", stderr) + } + return fmt.Errorf("devcontainer --version failed: %w", err) + } + return nil +} + +// installDir returns the directory where the CLI should be installed. +func (r *CLIResolver) installDir() string { + return filepath.Join(r.homeDir, ".local", "share", "headjack", cliInstallDir) +} + +// noNpmError returns an error with instructions when npm is not available. +func (r *CLIResolver) noNpmError() error { + return errors.New(`devcontainer CLI not found + +The devcontainer CLI is required to use devcontainer.json configurations. + +To install: + npm install -g @devcontainers/cli + +Or install Node.js first: + https://nodejs.org/ + +See: https://github.com/devcontainers/cli`) +} + +// declinedError returns an error with instructions when user declines installation. +func (r *CLIResolver) declinedError() error { + return errors.New(`devcontainer CLI not found + +To install manually: + npm install -g @devcontainers/cli + +Or use a container image instead: + hjk run --image `) +} diff --git a/internal/prompt/mocks/prompter.go b/internal/prompt/mocks/prompter.go new file mode 100644 index 0000000..46e3540 --- /dev/null +++ b/internal/prompt/mocks/prompter.go @@ -0,0 +1,220 @@ +// Code generated by moq; DO NOT EDIT. +// github.com/matryer/moq + +package mocks + +import ( + "sync" + + "github.com/jmgilman/headjack/internal/prompt" +) + +// Ensure, that PrompterMock does implement prompt.Prompter. +// If this is not the case, regenerate this file with moq. +var _ prompt.Prompter = &PrompterMock{} + +// PrompterMock is a mock implementation of prompt.Prompter. +// +// func TestSomethingThatUsesPrompter(t *testing.T) { +// +// // make and configure a mocked prompt.Prompter +// mockedPrompter := &PrompterMock{ +// ChoiceFunc: func(prompt string, options []string) (int, error) { +// panic("mock out the Choice method") +// }, +// ConfirmFunc: func(title string, description string) (bool, error) { +// panic("mock out the Confirm method") +// }, +// PrintFunc: func(message string) { +// panic("mock out the Print method") +// }, +// SecretFunc: func(prompt string) (string, error) { +// panic("mock out the Secret method") +// }, +// } +// +// // use mockedPrompter in code that requires prompt.Prompter +// // and then make assertions. +// +// } +type PrompterMock struct { + // ChoiceFunc mocks the Choice method. + ChoiceFunc func(prompt string, options []string) (int, error) + + // ConfirmFunc mocks the Confirm method. + ConfirmFunc func(title string, description string) (bool, error) + + // PrintFunc mocks the Print method. + PrintFunc func(message string) + + // SecretFunc mocks the Secret method. + SecretFunc func(prompt string) (string, error) + + // calls tracks calls to the methods. + calls struct { + // Choice holds details about calls to the Choice method. + Choice []struct { + // Prompt is the prompt argument value. + Prompt string + // Options is the options argument value. + Options []string + } + // Confirm holds details about calls to the Confirm method. + Confirm []struct { + // Title is the title argument value. + Title string + // Description is the description argument value. + Description string + } + // Print holds details about calls to the Print method. + Print []struct { + // Message is the message argument value. + Message string + } + // Secret holds details about calls to the Secret method. + Secret []struct { + // Prompt is the prompt argument value. + Prompt string + } + } + lockChoice sync.RWMutex + lockConfirm sync.RWMutex + lockPrint sync.RWMutex + lockSecret sync.RWMutex +} + +// Choice calls ChoiceFunc. +func (mock *PrompterMock) Choice(prompt string, options []string) (int, error) { + if mock.ChoiceFunc == nil { + panic("PrompterMock.ChoiceFunc: method is nil but Prompter.Choice was just called") + } + callInfo := struct { + Prompt string + Options []string + }{ + Prompt: prompt, + Options: options, + } + mock.lockChoice.Lock() + mock.calls.Choice = append(mock.calls.Choice, callInfo) + mock.lockChoice.Unlock() + return mock.ChoiceFunc(prompt, options) +} + +// ChoiceCalls gets all the calls that were made to Choice. +// Check the length with: +// +// len(mockedPrompter.ChoiceCalls()) +func (mock *PrompterMock) ChoiceCalls() []struct { + Prompt string + Options []string +} { + var calls []struct { + Prompt string + Options []string + } + mock.lockChoice.RLock() + calls = mock.calls.Choice + mock.lockChoice.RUnlock() + return calls +} + +// Confirm calls ConfirmFunc. +func (mock *PrompterMock) Confirm(title string, description string) (bool, error) { + if mock.ConfirmFunc == nil { + panic("PrompterMock.ConfirmFunc: method is nil but Prompter.Confirm was just called") + } + callInfo := struct { + Title string + Description string + }{ + Title: title, + Description: description, + } + mock.lockConfirm.Lock() + mock.calls.Confirm = append(mock.calls.Confirm, callInfo) + mock.lockConfirm.Unlock() + return mock.ConfirmFunc(title, description) +} + +// ConfirmCalls gets all the calls that were made to Confirm. +// Check the length with: +// +// len(mockedPrompter.ConfirmCalls()) +func (mock *PrompterMock) ConfirmCalls() []struct { + Title string + Description string +} { + var calls []struct { + Title string + Description string + } + mock.lockConfirm.RLock() + calls = mock.calls.Confirm + mock.lockConfirm.RUnlock() + return calls +} + +// Print calls PrintFunc. +func (mock *PrompterMock) Print(message string) { + if mock.PrintFunc == nil { + panic("PrompterMock.PrintFunc: method is nil but Prompter.Print was just called") + } + callInfo := struct { + Message string + }{ + Message: message, + } + mock.lockPrint.Lock() + mock.calls.Print = append(mock.calls.Print, callInfo) + mock.lockPrint.Unlock() + mock.PrintFunc(message) +} + +// PrintCalls gets all the calls that were made to Print. +// Check the length with: +// +// len(mockedPrompter.PrintCalls()) +func (mock *PrompterMock) PrintCalls() []struct { + Message string +} { + var calls []struct { + Message string + } + mock.lockPrint.RLock() + calls = mock.calls.Print + mock.lockPrint.RUnlock() + return calls +} + +// Secret calls SecretFunc. +func (mock *PrompterMock) Secret(prompt string) (string, error) { + if mock.SecretFunc == nil { + panic("PrompterMock.SecretFunc: method is nil but Prompter.Secret was just called") + } + callInfo := struct { + Prompt string + }{ + Prompt: prompt, + } + mock.lockSecret.Lock() + mock.calls.Secret = append(mock.calls.Secret, callInfo) + mock.lockSecret.Unlock() + return mock.SecretFunc(prompt) +} + +// SecretCalls gets all the calls that were made to Secret. +// Check the length with: +// +// len(mockedPrompter.SecretCalls()) +func (mock *PrompterMock) SecretCalls() []struct { + Prompt string +} { + var calls []struct { + Prompt string + } + mock.lockSecret.RLock() + calls = mock.calls.Secret + mock.lockSecret.RUnlock() + return calls +} diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go new file mode 100644 index 0000000..65e9bb5 --- /dev/null +++ b/internal/prompt/prompt.go @@ -0,0 +1,115 @@ +// Package prompt provides user interaction primitives using charmbracelet/huh. +package prompt + +import ( + "errors" + "fmt" + "strings" + + "github.com/charmbracelet/huh" +) + +// ErrCanceled is returned when the user cancels a prompt. +var ErrCanceled = errors.New("canceled by user") + +// Prompter abstracts user interaction for testability. +// +//go:generate go run github.com/matryer/moq@latest -pkg mocks -out mocks/prompter.go . Prompter +type Prompter interface { + // Print outputs text to the user. + Print(message string) + + // Confirm prompts for yes/no confirmation. + Confirm(title, description string) (bool, error) + + // Secret prompts for secret input (no echo). + Secret(prompt string) (string, error) + + // Choice prompts user to select from options, returns 0-based index. + Choice(prompt string, options []string) (int, error) +} + +// HuhPrompter implements Prompter using charmbracelet/huh for interactive forms. +type HuhPrompter struct{} + +// New creates a new HuhPrompter for interactive terminal prompts. +func New() *HuhPrompter { + return &HuhPrompter{} +} + +// Print outputs text to the user. +func (p *HuhPrompter) Print(message string) { + fmt.Println(message) +} + +// Confirm prompts for yes/no confirmation. +func (p *HuhPrompter) Confirm(title, description string) (bool, error) { + var confirmed bool + + err := huh.NewConfirm(). + Title(title). + Description(description). + Affirmative("Yes"). + Negative("No"). + Value(&confirmed). + Run() + + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return false, ErrCanceled + } + return false, fmt.Errorf("confirm prompt: %w", err) + } + + return confirmed, nil +} + +// Secret prompts for secret input with masked display. +func (p *HuhPrompter) Secret(prompt string) (string, error) { + var value string + + err := huh.NewInput(). + Title(prompt). + EchoMode(huh.EchoModePassword). + Value(&value). + Run() + + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return "", ErrCanceled + } + return "", fmt.Errorf("secret prompt: %w", err) + } + + return strings.TrimSpace(value), nil +} + +// Choice prompts user to select from options and returns the 0-based index. +func (p *HuhPrompter) Choice(prompt string, options []string) (int, error) { + if len(options) == 0 { + return 0, errors.New("no options provided") + } + + // Build huh options with display labels and index values + huhOptions := make([]huh.Option[int], len(options)) + for i, opt := range options { + huhOptions[i] = huh.NewOption(opt, i) + } + + var selected int + + err := huh.NewSelect[int](). + Title(prompt). + Options(huhOptions...). + Value(&selected). + Run() + + if err != nil { + if errors.Is(err, huh.ErrUserAborted) { + return 0, ErrCanceled + } + return 0, fmt.Errorf("choice prompt: %w", err) + } + + return selected, nil +}