Ask before removing a secret, version, vault or unlocker (closes #39)
check / check (push) Failing after 3s

secret rm, secret version rm, secret vault remove and secret unlocker
remove ask [y/N] on a terminal, naming what they remove, and go ahead
only on y or yes. Without --force, a command whose stdin is not a
terminal fails at once. --force, now also on rm and version rm,
removes without asking; it replaces the old refusals to remove a vault
with secrets or the last unlocker without --force. The checks run and
the question is asked before the state directory lock is taken; under
the lock the checks run again, and nothing is removed if they would
ask a different question.

Model: opus-5-5
This commit is contained in:
2026-10-04 15:18:17 +00:00
parent 7e4e0f7806
commit b4a03c595e
17 changed files with 1146 additions and 256 deletions
+68 -29
View File
@@ -205,19 +205,25 @@ func newRemoveCmd() *cobra.Command {
Aliases: []string{"rm"},
Short: "Remove a secret from the vault",
Long: `Remove a secret and all its versions from the current ` +
`vault. This action is permanent and cannot be undone.`,
`vault. This action is permanent and cannot be undone. ` +
`Asks for confirmation first; when stdin is not a terminal, ` +
`fails unless --force is given.`,
Args: cobra.ExactArgs(1),
ValidArgsFunction: getSecretNamesCompletionFunc(cli.fs, cli.stateDir),
RunE: func(cmd *cobra.Command, args []string) error {
force, _ := cmd.Flags().GetBool("force")
cli, err := NewCLIInstance()
if err != nil {
return fmt.Errorf("failed to initialize CLI: %w", err)
}
return cli.RemoveSecret(cmd, args[0], false)
return cli.RemoveSecret(cmd, args[0], force)
},
}
cmd.Flags().BoolP("force", "f", false, "Remove without asking for confirmation")
return cmd
}
@@ -699,29 +705,64 @@ func (cli *Instance) ImportSecret(
return nil
}
// RemoveSecret removes a secret from the vault
func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool) error {
// RemoveSecret removes a secret and all its versions from the current
// vault, after asking the user to confirm unless force is set.
func (cli *Instance) RemoveSecret(
cmd *cobra.Command, secretName string, force bool,
) error {
err := vault.ValidateSecretName(secretName)
if err != nil {
return err
}
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
var found secretToRemove
release, err := cli.askThenLock(cmd, force, func() (string, error) {
var err error
found, err = cli.findSecretToRemove(secretName)
return found.question, err
})
if err != nil {
return err
}
defer release()
// Get current vault
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
err = secret.RemoveDirAtomic(cli.fs, found.dir)
if err != nil {
return err
return fmt.Errorf("failed to remove secret: %w", err)
}
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n",
secretName, found.versions)
return nil
}
// secretToRemove is what removing a secret removes, as findSecretToRemove
// found it.
type secretToRemove struct {
// dir is the secret's directory, which holds all its versions.
dir string
versions int
// question names what is removed, for the user to confirm.
question string
}
// findSecretToRemove checks that the secret exists in the current vault
// and counts its versions.
func (cli *Instance) findSecretToRemove(
secretName string,
) (secretToRemove, error) {
currentVlt, err := vault.GetCurrentVault(cli.fs, cli.stateDir)
if err != nil {
return secretToRemove{}, err
}
// Check if secret exists
vaultDir, err := currentVlt.GetDirectory()
if err != nil {
return err
return secretToRemove{}, err
}
encodedName := strings.ReplaceAll(secretName, "/", "%")
@@ -729,32 +770,30 @@ func (cli *Instance) RemoveSecret(cmd *cobra.Command, secretName string, _ bool)
exists, err := afero.DirExists(cli.fs, secretDir)
if err != nil {
return fmt.Errorf("failed to check if secret exists: %w", err)
return secretToRemove{},
fmt.Errorf("failed to check if secret exists: %w", err)
}
if !exists {
return fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
return secretToRemove{},
fmt.Errorf("secret '%s' %w", secretName, errSecretNotFound)
}
// Count versions for information
versionsDir := filepath.Join(secretDir, "versions")
versionCount := 0
entries, err := afero.ReadDir(cli.fs, versionsDir)
if err == nil {
versionCount = len(entries)
// A secret without a versions directory has no versions, and can
// still be removed.
versions, err := afero.ReadDir(cli.fs, filepath.Join(secretDir, "versions"))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return secretToRemove{}, fmt.Errorf(
"failed to count the versions of secret '%s': %w", secretName, err)
}
// Remove the secret directory
err = secret.RemoveDirAtomic(cli.fs, secretDir)
if err != nil {
return fmt.Errorf("failed to remove secret: %w", err)
}
cmd.Printf("Removed secret '%s' (%d version(s) deleted)\n",
secretName, versionCount)
return nil
return secretToRemove{
dir: secretDir,
versions: len(versions),
question: fmt.Sprintf("Permanently remove secret '%s' and its %d "+
"version(s) from vault '%s'?",
secretName, len(versions), currentVlt.GetName()),
}, nil
}
// MoveSecret moves or renames a secret (within or across vaults), holding