From 8c0f9abe55425043c12d96f510419e395cd57535 Mon Sep 17 00:00:00 2001 From: Joshua Gilman Date: Sun, 4 Jan 2026 21:02:25 -0800 Subject: [PATCH] feat(devcontainer): add smart CLI fallback with local npm installation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Current behavior: When devcontainer CLI is not found in PATH, the command fails with an error message telling users to install it manually. New behavior: The CLI resolution now follows a progressive fallback: 1. Check if devcontainer is in PATH 2. Check if devcontainer.path is set in config and binary exists 3. If npm is available, prompt user to install CLI locally 4. Install to ~/.local/share/headjack/devcontainer-cli/ and save path to config Also extracts the huh-based prompter from internal/auth into a shared internal/prompt package for reuse across the codebase. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/auth/auth.go | 14 -- internal/auth/mocks/prompter.go | 170 ----------------------- internal/auth/prompter.go | 72 ---------- internal/cmd/auth.go | 13 +- internal/cmd/run.go | 45 +++--- internal/config/config.go | 16 ++- internal/devcontainer/cli.go | 208 ++++++++++++++++++++++++++++ internal/prompt/mocks/prompter.go | 220 ++++++++++++++++++++++++++++++ internal/prompt/prompt.go | 115 ++++++++++++++++ 9 files changed, 582 insertions(+), 291 deletions(-) delete mode 100644 internal/auth/mocks/prompter.go delete mode 100644 internal/auth/prompter.go create mode 100644 internal/devcontainer/cli.go create mode 100644 internal/prompt/mocks/prompter.go create mode 100644 internal/prompt/prompt.go 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 +}