Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions pkg/flags/interactive.go
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,49 @@ func PromptForPositionalArg(argName, promptTitle string, completionFunc Completi

return PromptForValue(argName, promptTitle, options)
}

// PromptForConfirmation prompts for yes/no confirmation on destructive actions.
// This is Use Case 4: Destructive Action Confirmation.
//
// Returns true if:
// - force is true (bypass confirmation).
// - user confirms in interactive TTY.
//
// Returns false with nil error if user declines.
// Returns false with ErrInteractiveNotAvailable if not TTY and not forced.
// Returns false with ErrUserAborted if user presses Ctrl+C/ESC.
//
// Example:
//
// confirmed, err := flags.PromptForConfirmation("Delete directory: /path/to/dir?", force)
// if err != nil { return err }
// if !confirmed { return nil }
func PromptForConfirmation(title string, force bool) (bool, error) {
defer perf.Track(nil, "flags.PromptForConfirmation")()

if force {
return true, nil
}

// Check if stdin is a TTY.
if !atmosterm.IsTTYSupportForStdin() {
return false, errUtils.ErrInteractiveNotAvailable
}

var confirmed bool
prompt := huh.NewConfirm().
Title(title).
Affirmative("Yes!").
Negative("No.").
Value(&confirmed).
WithTheme(uiutils.NewAtmosHuhTheme())

if err := prompt.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
return false, errUtils.ErrUserAborted
}
return false, fmt.Errorf("confirmation prompt failed: %w", err)
}

return confirmed, nil
}
32 changes: 32 additions & 0 deletions pkg/flags/interactive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

errUtils "github.com/cloudposse/atmos/errors"
)

// TestIsInteractive tests the isInteractive function.
Expand Down Expand Up @@ -731,6 +733,36 @@ func TestStandardFlagParser_PromptForMissingPositionalArgs_MultipleArgs(t *testi
})
}

// TestPromptForConfirmation tests the PromptForConfirmation function.
func TestPromptForConfirmation(t *testing.T) {
t.Run("returns true immediately when force is true", func(t *testing.T) {
confirmed, err := PromptForConfirmation("Delete this?", true)
assert.NoError(t, err, "should not return error when force is true")
assert.True(t, confirmed, "should return true when force is true")
})

t.Run("returns true immediately when force is true with any title", func(t *testing.T) {
confirmed, err := PromptForConfirmation("Are you sure you want to destroy everything?", true)
assert.NoError(t, err, "should not return error when force is true")
assert.True(t, confirmed, "should return true when force is true regardless of title")
})

t.Run("returns ErrInteractiveNotAvailable in non-TTY environment", func(t *testing.T) {
// In test environment, stdin is typically not a TTY.
confirmed, err := PromptForConfirmation("Delete this?", false)
// The result depends on the environment, but typically in tests:
// - Either we get ErrInteractiveNotAvailable (no TTY)
// - Or the test would hang waiting for input (TTY present)
// Since tests run non-interactively, we expect the error.
if err != nil {
assert.ErrorIs(t, err, errUtils.ErrInteractiveNotAvailable, "should return ErrInteractiveNotAvailable in non-TTY")
assert.False(t, confirmed, "should return false when interactive not available")
}
// If no error and test environment has TTY, the test would hang.
// This is acceptable - the main coverage is for the force=true path.
})
}

// TestIsInteractive_TTYAndCIBehavior tests TTY and CI detection behavior.
func TestIsInteractive_TTYAndCIBehavior(t *testing.T) {
// Save original viper state.
Expand Down
104 changes: 76 additions & 28 deletions pkg/provisioner/source/cmd/delete.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"errors"
"fmt"
"os"

Expand All @@ -14,6 +15,7 @@ import (
"github.com/cloudposse/atmos/pkg/provisioner/source"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/ui"
"github.com/cloudposse/atmos/pkg/ui/spinner"
)

// DeleteCommand creates a delete command for the given component type.
Expand All @@ -24,15 +26,19 @@ func DeleteCommand(config *Config) *cobra.Command {
)

cmd := &cobra.Command{
Use: "delete <component>",
Use: "delete [component]",
Short: fmt.Sprintf("Remove vendored %s source directory", config.TypeLabel),
Long: fmt.Sprintf(`Delete the vendored source directory for a %s component.

This command removes the component directory that was created by 'atmos %s source pull'.
Requires --force flag for safety.`, config.TypeLabel, config.ComponentType),

If component is not specified, prompts interactively for selection.`, config.TypeLabel, config.ComponentType),
Example: fmt.Sprintf(` # Delete vendored source
atmos %s source delete vpc --stack dev --force`, config.ComponentType),
Args: cobra.ExactArgs(1),
atmos %s source delete vpc --stack dev --force

# Interactive: prompts for component and stack
atmos %s source delete`, config.ComponentType, config.ComponentType),
Args: cobra.RangeArgs(0, 1),
RunE: func(cmd *cobra.Command, args []string) error {
return executeDelete(cmd, args, config, parser)
},
Expand All @@ -51,16 +57,34 @@ Requires --force flag for safety.`, config.TypeLabel, config.ComponentType),
// deleteOptions holds parsed delete command options.
type deleteOptions struct {
Stack string
Force bool
GlobalFlags global.Flags
}

func executeDelete(cmd *cobra.Command, args []string, config *Config, parser *flags.StandardParser) error {
defer perf.Track(nil, fmt.Sprintf("source.%s.delete.RunE", config.ComponentType))()

component := args[0]
// Get component from args or prompt.
var component string
if len(args) > 0 {
component = args[0]
} else {
var promptErr error
component, promptErr = PromptForComponent(cmd)
if err := HandlePromptError(promptErr, "component"); err != nil {
return err
}
}

// Validate component is provided.
if component == "" {
return errUtils.Build(errUtils.ErrInvalidPositionalArgs).
WithExplanation("component argument is required").
Err()
}

// Parse flags and get delete options.
deleteOpts, err := parseDeleteFlags(cmd, parser)
// Parse flags and get delete options (with prompting).
deleteOpts, err := parseDeleteFlags(cmd, parser, component)
if err != nil {
return err
}
Expand All @@ -72,33 +96,40 @@ func executeDelete(cmd *cobra.Command, args []string, config *Config, parser *fl
}

// Determine and delete the target directory.
return deleteSourceDirectory(atmosConfig, config.ComponentType, component, componentConfig)
return deleteSourceDirectory(atmosConfig, config.ComponentType, component, componentConfig, deleteOpts.Force)
}

// parseDeleteFlags parses delete command flags and validates them.
func parseDeleteFlags(cmd *cobra.Command, parser *flags.StandardParser) (*deleteOptions, error) {
func parseDeleteFlags(cmd *cobra.Command, parser *flags.StandardParser, component string) (*deleteOptions, error) {
v := viper.GetViper()
if err := parser.BindFlagsToViper(cmd, v); err != nil {
return nil, err
}

globalFlags := flags.ParseGlobalFlags(cmd, v)
stack := v.GetString("stack")

// Prompt for stack if not provided.
if stack == "" {
return nil, errUtils.Build(errUtils.ErrRequiredFlagNotProvided).
WithExplanation("--stack flag is required").
Err()
var promptErr error
stack, promptErr = PromptForStack(cmd, component)
if err := HandlePromptError(promptErr, "stack"); err != nil {
return nil, err
}
}

force := v.GetBool("force")
if !force {
return nil, errUtils.Build(errUtils.ErrForceRequired).
WithExplanation("Deletion requires --force flag for safety").
WithHint("Use --force to confirm deletion").
// Validate stack is provided.
if stack == "" {
return nil, errUtils.Build(errUtils.ErrRequiredFlagNotProvided).
WithExplanation("--stack flag is required").
Err()
}

return &deleteOptions{Stack: stack, GlobalFlags: globalFlags}, nil
return &deleteOptions{
Stack: stack,
Force: v.GetBool("force"),
GlobalFlags: globalFlags,
}, nil
}

// initDeleteContext initializes config and retrieves component configuration.
Expand Down Expand Up @@ -143,12 +174,12 @@ func initDeleteContext(component, stack string, globalFlags *global.Flags) (*sch
}

// deleteSourceDirectory deletes the vendored source directory.
func deleteSourceDirectory(atmosConfig *schema.AtmosConfiguration, componentType, component string, componentConfig map[string]any) error {
func deleteSourceDirectory(atmosConfig *schema.AtmosConfiguration, componentType, component string, componentConfig map[string]any, force bool) error {
targetDir, err := source.DetermineTargetDirectory(atmosConfig, componentType, component, componentConfig)
if err != nil {
return errUtils.Build(errUtils.ErrSourceProvision).
WithCause(err).
WithExplanation("Failed to determine target directory").
WithContext("component", component).
Err()
}

Expand All @@ -157,14 +188,31 @@ func deleteSourceDirectory(atmosConfig *schema.AtmosConfiguration, componentType
return nil
}

_ = ui.Info(fmt.Sprintf("Deleting directory: %s", targetDir))
if err := os.RemoveAll(targetDir); err != nil {
return errUtils.Build(errUtils.ErrRemoveDirectory).
WithCause(err).
WithContext("path", targetDir).
Err()
// Prompt for confirmation unless --force.
confirmed, err := flags.PromptForConfirmation(fmt.Sprintf("Delete directory: %s?", targetDir), force)
if err != nil {
if errors.Is(err, errUtils.ErrInteractiveNotAvailable) {
_ = ui.Warning("Use --force to delete in non-interactive mode")
}
return err
}
if !confirmed {
_ = ui.Info("Deletion cancelled")
return nil
}

_ = ui.Success(fmt.Sprintf("Successfully deleted: %s", targetDir))
return nil
// Delete with spinner.
return spinner.ExecWithSpinner(
fmt.Sprintf("Deleting %s", targetDir),
fmt.Sprintf("Deleted %s", targetDir),
func() error {
if err := os.RemoveAll(targetDir); err != nil {
return errUtils.Build(errUtils.ErrRemoveDirectory).
WithCause(err).
WithContext("path", targetDir).
Err()
}
return nil
},
)
}
Loading
Loading