diff --git a/.changesets/1792716255-70167bd5.yaml b/.changesets/1792716255-70167bd5.yaml new file mode 100644 index 000000000..b09b158df --- /dev/null +++ b/.changesets/1792716255-70167bd5.yaml @@ -0,0 +1,12 @@ +id: 1792716255-70167bd5 +features: + - core + - globals + - globalSecurity +targets: + - cli +type: feat +bump: patch +description: add configure --clear to remove stored credentials, global parameters and the default output format, and name the supported OS credential stores in whoami help +author: TristanSpeakEasy +date: "2026-10-23" diff --git a/templates/templates/cli/auth.go.stmpl b/templates/templates/cli/auth.go.stmpl index 00a3f509e..0fb46c2af 100644 --- a/templates/templates/cli/auth.go.stmpl +++ b/templates/templates/cli/auth.go.stmpl @@ -66,7 +66,9 @@ Subcommands: Sources are shown as: [flag] - Set via command line flag [env] - Set via environment variable ({{.Global.Config.EnvVarPrefix}}_*) - [keyring] - Set via OS keychain (stored by login/configure command) +{{- if hasGlobalSecurity}} + [keyring] - Set via OS credential store: macOS Keychain, Windows Credential Manager or Secret Service (stored by login/configure command) +{{- end}} [config] - Set via config file (~/.config/{{sanitizeCliName}}/config.yaml) [unset] - Not configured {{- if hasGlobalSecurity}} diff --git a/templates/templates/cli/auxiliary/internal/config/keyring.go.stmpl b/templates/templates/cli/auxiliary/internal/config/keyring.go.stmpl index 13b223652..e9b0df996 100644 --- a/templates/templates/cli/auxiliary/internal/config/keyring.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/config/keyring.go.stmpl @@ -170,6 +170,20 @@ func DeleteKeyringValue(key string) error { return withKeyringTimeout(func() error { return backend.Delete(cliName, key) }) } +// DeleteStoredSecret removes a credential from the OS keychain when it is +// available. A credential that is not stored there, or a keychain that stops +// responding (already reported as skipped), is not an error. +func DeleteStoredSecret(key string) error { + if !KeyringAvailable() { + return nil + } + err := DeleteKeyringValue(key) + if err == nil || errors.Is(err, keyring.ErrNotFound) || errors.Is(err, ErrKeyringUnavailable) { + return nil + } + return err +} + // KeyringAvailable returns whether the OS keychain is accessible. // Useful for configure command to decide where to store secrets. func KeyringAvailable() bool { diff --git a/templates/templates/cli/configure.go.stmpl b/templates/templates/cli/configure.go.stmpl index 2c8cffee7..c5bc9654b 100644 --- a/templates/templates/cli/configure.go.stmpl +++ b/templates/templates/cli/configure.go.stmpl @@ -40,6 +40,7 @@ func initConfigureCmd(parent *cobra.Command) error { {{- end}} {{- if isAnyInteractiveEnabled}} cmd.Flags().String("{{defaultOutputFormatFlag}}", "", "Store the default output format without opening the form. Options: "+strings.Join(output.Formats, ", ")+". Pass an empty value to clear it.") + cmd.Flags().StringSlice("{{configureClearFlag}}", nil, "Remove stored values without opening the form (repeatable or comma-separated). Options: {{templateConfigureClearOptions}}") {{- end}} parent.AddCommand(cmd) return nil @@ -63,8 +64,9 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { {{- end}} {{- if isAnyInteractiveEnabled}} + var cleared []string formMode := interactive.Resolve(cmd).SetupFormMode(flagutil.AnyFlagChanged(cmd, {{templateConfigureFlagNames}})) - if cmd.Flags().Changed("{{defaultOutputFormatFlag}}") { + if cmd.Flags().Changed("{{defaultOutputFormatFlag}}") || cmd.Flags().Changed("{{configureClearFlag}}") { formMode = interactive.FormOff } {{- end}} @@ -175,6 +177,11 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { fmt.Fprintln(out, "Secret credentials stored in OS keychain") } {{- end}} + {{- if isAnyInteractiveEnabled}} + for _, name := range cleared { + fmt.Fprintf(out, "Cleared --%s\n", name) + } + {{- end}} fmt.Fprintf(out, "Configuration saved to %s\n", config.GetConfigPath()) return nil } diff --git a/templates/templates/cli/includes/security.ts b/templates/templates/cli/includes/security.ts index 51aed2638..b250ed4d5 100644 --- a/templates/templates/cli/includes/security.ts +++ b/templates/templates/cli/includes/security.ts @@ -2721,6 +2721,8 @@ function templateConfigureNonInteractiveStore(): string { } } + lines.push(...genConfigureClear()); + lines.push(``); lines.push(`if !changed {`); lines.push( @@ -2735,6 +2737,77 @@ registerTemplateFunc( templateConfigureNonInteractiveStore, ); +const CONFIGURE_CLEAR_FLAG = "clear"; + +function configureClearableNames(): string[] { + return [...storedFlagNames("configure"), DEFAULT_OUTPUT_FORMAT_FLAG]; +} + +function genConfigureClear(): string[] { + const clearable = configureClearableNames(); + const options = clearable.join(", "); + const lines: string[] = []; + lines.push( + `if f := cmd.Flags().Lookup("${CONFIGURE_CLEAR_FLAG}"); f != nil && f.Changed {`, + ); + lines.push( + ` names, _ := cmd.Flags().GetStringSlice("${CONFIGURE_CLEAR_FLAG}")`, + ); + lines.push(` keys := make([]string, 0, len(names))`); + lines.push(` for _, name := range names {`); + lines.push( + ` switch key := strings.TrimPrefix(strings.TrimSpace(name), "--"); key {`, + ); + lines.push(` case ${clearable.map((n) => `"${n}"`).join(", ")}:`); + lines.push(` keys = append(keys, key)`); + lines.push(` default:`); + lines.push( + ` return flagutil.WithCLIValidation(fmt.Errorf("invalid --${CONFIGURE_CLEAR_FLAG} %q; options: ${escapeGoString( + options, + )}", name))`, + ); + lines.push(` }`); + lines.push(` }`); + lines.push(` for _, key := range keys {`); + lines.push(` switch key {`); + + const secAcc = securityConfigAccessor(); + for (const field of getCLISecurityFields()) { + const cfgField = `${secAcc}${caser().ToPascal(field.field.Name)}`; + lines.push(` case "${field.flagName}":`); + if (field.isSecret) { + lines.push( + ` if err := config.DeleteStoredSecret("${field.flagName}"); err != nil {`, + ); + lines.push( + ` return fmt.Errorf("failed to remove --${field.flagName} from the OS keychain: %w", err)`, + ); + lines.push(` }`); + } + lines.push(` ${cfgField} = ${field.isArray ? "nil" : `""`}`); + } + if (hasGlobals()) { + const globAcc = globalsConfigAccessor(); + for (const field of context.Global.AST.MainSDK.Globals.Fields) { + lines.push(` case "${sanitizeFlagNameWithReserved(field.Name)}":`); + lines.push(` ${globAcc}${caser().ToPascal(field.Name)} = ""`); + } + } + lines.push(` case "${DEFAULT_OUTPUT_FORMAT_FLAG}":`); + lines.push(` cfg.OutputFormat = ""`); + lines.push(` }`); + lines.push(` cleared = append(cleared, key)`); + lines.push(` }`); + lines.push(` changed = true`); + lines.push(`}`); + return lines; +} + +registerTemplateFunc("configureClearFlag", () => CONFIGURE_CLEAR_FLAG); +registerTemplateFunc("templateConfigureClearOptions", () => + escapeGoString(configureClearableNames().join(", ")), +); + registerTemplateFunc( "defaultOutputFormatFlag", () => DEFAULT_OUTPUT_FORMAT_FLAG, @@ -2763,6 +2836,7 @@ function templateConfigureFlagNames(): string { return goFlagNameArgs([ ...storedFlagNames("configure"), DEFAULT_OUTPUT_FORMAT_FLAG, + CONFIGURE_CLEAR_FLAG, ]); } registerTemplateFunc("templateConfigureFlagNames", templateConfigureFlagNames); diff --git a/templates/templates/cli/tests/primary/configure_test.go.stmpl b/templates/templates/cli/tests/primary/configure_test.go.stmpl index 842204b66..e425d873c 100644 --- a/templates/templates/cli/tests/primary/configure_test.go.stmpl +++ b/templates/templates/cli/tests/primary/configure_test.go.stmpl @@ -3,6 +3,7 @@ package tests import ( "context" "encoding/json" + "errors" "os" "path/filepath" "testing" @@ -308,6 +309,59 @@ func TestConfigureNoInteractiveStoresFlags(t *testing.T) { assert.Contains(t, string(data), "username: flag-only-user") } +type deleteFailingKeyring struct{ recordingKeyring } + +func (k *deleteFailingKeyring) Delete(service, key string) error { + k.deletes++ + return errors.New("delete denied") +} + +func runConfigureClear(t *testing.T, backend config.KeyringBackend, clear string) (string, error) { + t.Helper() + h, home := setupConfigureTest(t) + config.SetKeyringBackend(backend) + t.Cleanup(config.ResetKeyring) + writeConfigFile(t, home, map[string]interface{}{ + "security": map[string]interface{}{"api_key_auth": "stored-key"}, + "globals": map[string]interface{}{"global_query_param": "stored-query"}, + }) + rootCmd, err := cli.NewRootCommand() + require.NoError(t, err) + rootCmd.SetOut(h.stdout) + rootCmd.SetErr(h.stderr) + runErr := cli.ExecuteRoot(context.Background(), rootCmd, []string{"configure", "--no-interactive", "--clear", clear}) + data, err := os.ReadFile(cliConfigPath(home)) + require.NoError(t, err) + return string(data), runErr +} + +func TestConfigureClearRemovesValues(t *testing.T) { + recorder := &recordingKeyring{} + data, err := runConfigureClear(t, recorder, "api-key-auth,--global-query-param") + require.NoError(t, err) + assert.NotContains(t, data, "stored-key") + assert.NotContains(t, data, "stored-query") + assert.Equal(t, 1, recorder.deletes) +} + +func TestConfigureClearValidatesEveryNameFirst(t *testing.T) { + recorder := &recordingKeyring{} + data, err := runConfigureClear(t, recorder, "api-key-auth,not-a-setting") + require.Error(t, err) + assert.Contains(t, err.Error(), `invalid --clear "not-a-setting"`) + assert.Contains(t, data, "stored-key") + assert.Zero(t, recorder.deletes) +} + +func TestConfigureClearReportsKeychainDeleteFailure(t *testing.T) { + backend := &deleteFailingKeyring{} + data, err := runConfigureClear(t, backend, "api-key-auth") + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to remove --api-key-auth from the OS keychain") + assert.Contains(t, data, "stored-key") + assert.Equal(t, 1, backend.deletes) +} + func TestLocalMutationCommandsDryRunAreNoOps(t *testing.T) { for _, tt := range []struct { name string diff --git a/templates/templates/cli/whoami.go.stmpl b/templates/templates/cli/whoami.go.stmpl index 3674e0c82..0fb4f9020 100644 --- a/templates/templates/cli/whoami.go.stmpl +++ b/templates/templates/cli/whoami.go.stmpl @@ -18,7 +18,9 @@ func initWhoamiCmd(parent *cobra.Command) error { Sources are shown as: [flag] - Set via command line flag [env] - Set via environment variable ({{.Global.Config.EnvVarPrefix}}_*) - [keyring] - Set via OS keychain (stored by configure command) +{{- if hasGlobalSecurity}} + [keyring] - Set via OS credential store: macOS Keychain, Windows Credential Manager or Secret Service (stored by configure command) +{{- end}} [config] - Set via config file (~/.config/{{sanitizeCliName}}/config.yaml) [default] - Built-in global parameter flag default [unset] - Not configured diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 5b420f96d..9a693a3a0 100644 --- a/zSDKs/sdk-cli/.speakeasy/gen.lock +++ b/zSDKs/sdk-cli/.speakeasy/gen.lock @@ -60,13 +60,13 @@ trackedFiles: go.mod: last_write_checksum: sha1:15ced474cbb1ec803f17960c0b73f10ea969c22a internal/cli/auth.go: - last_write_checksum: sha1:5120764443e6565b7a63c06ad91d6323806cdfea + last_write_checksum: sha1:8c70569b35b5583ca7e8c23402a9e164d98f1d1b internal/cli/binaryandstringupload.go: last_write_checksum: sha1:20d8028b7618e73dcf1763372f9145ea325b32b0 internal/cli/chat.go: last_write_checksum: sha1:9a9077f9bd0eb3ef19d36d461243215c50f3442b internal/cli/configure.go: - last_write_checksum: sha1:91f681e0c0bb3ec3fccfe48939b8bb0b71d89619 + last_write_checksum: sha1:66da0edca07db643976427521977046f4bca216c internal/cli/createuser.go: last_write_checksum: sha1:5b60000059db85dcf4c90f5e67c37b7fff3cf852 internal/cli/createwithunion.go: @@ -222,7 +222,7 @@ trackedFiles: internal/cli/version.go: last_write_checksum: sha1:ef4d924ad83b7b2e9e4698a9f0ac229bac4060d4 internal/cli/whoami.go: - last_write_checksum: sha1:ad27d6ccfed17dd213951f981030b010ababbf79 + last_write_checksum: sha1:dbbfb30c1445584c4365afa9a81354a6721d8a50 internal/client/client.go: last_write_checksum: sha1:58db154dfc3b80e998471223136423d3ffc1ca92 internal/client/diagnostics.go: @@ -232,7 +232,7 @@ trackedFiles: internal/config/config.go: last_write_checksum: sha1:7d3e1555d3be75dcd615fb460f44bf1a4da784dd internal/config/keyring.go: - last_write_checksum: sha1:7c0201503038fee2f5eac51bc0f0189e06d3a224 + last_write_checksum: sha1:893474c3d2734f82532552c53eb3782623933eb1 internal/explorer/explorer.go: last_write_checksum: sha1:c149087aa4be52cdf0b3f9a41d8f0c6dbe47c53f internal/explorer/styles.go: diff --git a/zSDKs/sdk-cli/docs/cli_auth_whoami.md b/zSDKs/sdk-cli/docs/cli_auth_whoami.md index af9050168..5867210b1 100644 --- a/zSDKs/sdk-cli/docs/cli_auth_whoami.md +++ b/zSDKs/sdk-cli/docs/cli_auth_whoami.md @@ -9,7 +9,7 @@ Display the currently configured settings and their sources. Sources are shown as: [flag] - Set via command line flag [env] - Set via environment variable (CLI_*) - [keyring] - Set via OS keychain (stored by login/configure command) + [keyring] - Set via OS credential store: macOS Keychain, Windows Credential Manager or Secret Service (stored by login/configure command) [config] - Set via config file (~/.config/cli/config.yaml) [unset] - Not configured diff --git a/zSDKs/sdk-cli/docs/cli_configure.md b/zSDKs/sdk-cli/docs/cli_configure.md index 8b1f92c8d..654311c6a 100644 --- a/zSDKs/sdk-cli/docs/cli_configure.md +++ b/zSDKs/sdk-cli/docs/cli_configure.md @@ -20,6 +20,7 @@ cli configure [flags] ### Options ``` + --clear strings Remove stored values without opening the form (repeatable or comma-separated). Options: username, password, bearer-auth, my-api-key, oauth2, app-id, secret, mobile-auth, client-id, client-secret, token-url, query-param1, deprecated-query-param1, deprecated-query-param2, lone-query-param, default-output-format --default-output-format string Store the default output format without opening the form. Options: pretty, json, yaml, table, toon. Pass an empty value to clear it. -h, --help help for configure ``` diff --git a/zSDKs/sdk-cli/docs/cli_whoami.md b/zSDKs/sdk-cli/docs/cli_whoami.md index acce7f0ab..448753e7c 100644 --- a/zSDKs/sdk-cli/docs/cli_whoami.md +++ b/zSDKs/sdk-cli/docs/cli_whoami.md @@ -9,7 +9,7 @@ Display the currently configured settings and their sources. Sources are shown as: [flag] - Set via command line flag [env] - Set via environment variable (CLI_*) - [keyring] - Set via OS keychain (stored by configure command) + [keyring] - Set via OS credential store: macOS Keychain, Windows Credential Manager or Secret Service (stored by configure command) [config] - Set via config file (~/.config/cli/config.yaml) [default] - Built-in global parameter flag default [unset] - Not configured diff --git a/zSDKs/sdk-cli/internal/cli/auth.go b/zSDKs/sdk-cli/internal/cli/auth.go index 13d4974d7..7e13cc6f2 100644 --- a/zSDKs/sdk-cli/internal/cli/auth.go +++ b/zSDKs/sdk-cli/internal/cli/auth.go @@ -68,7 +68,7 @@ Use the configure command for both authentication and global parameters.`, Sources are shown as: [flag] - Set via command line flag [env] - Set via environment variable (CLI_*) - [keyring] - Set via OS keychain (stored by login/configure command) + [keyring] - Set via OS credential store: macOS Keychain, Windows Credential Manager or Secret Service (stored by login/configure command) [config] - Set via config file (~/.config/cli/config.yaml) [unset] - Not configured diff --git a/zSDKs/sdk-cli/internal/cli/configure.go b/zSDKs/sdk-cli/internal/cli/configure.go index 99a8b5daf..aac3f0a01 100644 --- a/zSDKs/sdk-cli/internal/cli/configure.go +++ b/zSDKs/sdk-cli/internal/cli/configure.go @@ -35,6 +35,7 @@ Priority: CLI flags > environment variables > OS keychain > config file`, } cmd.Annotations = map[string]string{"speakeasy_settings_command": "true"} cmd.Flags().String("default-output-format", "", "Store the default output format without opening the form. Options: "+strings.Join(output.Formats, ", ")+". Pass an empty value to clear it.") + cmd.Flags().StringSlice("clear", nil, "Remove stored values without opening the form (repeatable or comma-separated). Options: username, password, bearer-auth, my-api-key, oauth2, app-id, secret, mobile-auth, client-id, client-secret, token-url, query-param1, deprecated-query-param1, deprecated-query-param2, lone-query-param, default-output-format") parent.AddCommand(cmd) return nil } @@ -54,8 +55,9 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { keychainStored := false - formMode := interactive.Resolve(cmd).SetupFormMode(flagutil.AnyFlagChanged(cmd, "username", "password", "bearer-auth", "my-api-key", "oauth2", "app-id", "secret", "mobile-auth", "client-id", "client-secret", "token-url", "query-param1", "deprecated-query-param1", "deprecated-query-param2", "lone-query-param", "default-output-format")) - if cmd.Flags().Changed("default-output-format") { + var cleared []string + formMode := interactive.Resolve(cmd).SetupFormMode(flagutil.AnyFlagChanged(cmd, "username", "password", "bearer-auth", "my-api-key", "oauth2", "app-id", "secret", "mobile-auth", "client-id", "client-secret", "token-url", "query-param1", "deprecated-query-param1", "deprecated-query-param2", "lone-query-param", "default-output-format", "clear")) + if cmd.Flags().Changed("default-output-format") || cmd.Flags().Changed("clear") { formMode = interactive.FormOff } if formMode == interactive.FormOff { @@ -160,6 +162,83 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { cfg.Globals.LoneQueryParam = f.Value.String() changed = true } + if f := cmd.Flags().Lookup("clear"); f != nil && f.Changed { + names, _ := cmd.Flags().GetStringSlice("clear") + keys := make([]string, 0, len(names)) + for _, name := range names { + switch key := strings.TrimPrefix(strings.TrimSpace(name), "--"); key { + case "username", "password", "bearer-auth", "my-api-key", "oauth2", "app-id", "secret", "mobile-auth", "client-id", "client-secret", "token-url", "query-param1", "deprecated-query-param1", "deprecated-query-param2", "lone-query-param", "default-output-format": + keys = append(keys, key) + default: + return flagutil.WithCLIValidation(fmt.Errorf("invalid --clear %q; options: username, password, bearer-auth, my-api-key, oauth2, app-id, secret, mobile-auth, client-id, client-secret, token-url, query-param1, deprecated-query-param1, deprecated-query-param2, lone-query-param, default-output-format", name)) + } + } + for _, key := range keys { + switch key { + case "username": + cfg.Security.Username = "" + case "password": + if err := config.DeleteStoredSecret("password"); err != nil { + return fmt.Errorf("failed to remove --password from the OS keychain: %w", err) + } + cfg.Security.Password = "" + case "bearer-auth": + if err := config.DeleteStoredSecret("bearer-auth"); err != nil { + return fmt.Errorf("failed to remove --bearer-auth from the OS keychain: %w", err) + } + cfg.Security.BearerAuth = "" + case "my-api-key": + if err := config.DeleteStoredSecret("my-api-key"); err != nil { + return fmt.Errorf("failed to remove --my-api-key from the OS keychain: %w", err) + } + cfg.Security.MyApiKey = "" + case "oauth2": + if err := config.DeleteStoredSecret("oauth2"); err != nil { + return fmt.Errorf("failed to remove --oauth2 from the OS keychain: %w", err) + } + cfg.Security.Oauth2 = "" + case "app-id": + if err := config.DeleteStoredSecret("app-id"); err != nil { + return fmt.Errorf("failed to remove --app-id from the OS keychain: %w", err) + } + cfg.Security.AppId = "" + case "secret": + if err := config.DeleteStoredSecret("secret"); err != nil { + return fmt.Errorf("failed to remove --secret from the OS keychain: %w", err) + } + cfg.Security.Secret = "" + case "mobile-auth": + if err := config.DeleteStoredSecret("mobile-auth"); err != nil { + return fmt.Errorf("failed to remove --mobile-auth from the OS keychain: %w", err) + } + cfg.Security.MobileAuth = "" + case "client-id": + if err := config.DeleteStoredSecret("client-id"); err != nil { + return fmt.Errorf("failed to remove --client-id from the OS keychain: %w", err) + } + cfg.Security.ClientID = "" + case "client-secret": + if err := config.DeleteStoredSecret("client-secret"); err != nil { + return fmt.Errorf("failed to remove --client-secret from the OS keychain: %w", err) + } + cfg.Security.ClientSecret = "" + case "token-url": + cfg.Security.TokenURL = "" + case "query-param1": + cfg.Globals.QueryParam1 = "" + case "deprecated-query-param1": + cfg.Globals.DeprecatedQueryParam1 = "" + case "deprecated-query-param2": + cfg.Globals.DeprecatedQueryParam2 = "" + case "lone-query-param": + cfg.Globals.LoneQueryParam = "" + case "default-output-format": + cfg.OutputFormat = "" + } + cleared = append(cleared, key) + } + changed = true + } if !changed { return flagutil.WithCLIValidation(fmt.Errorf("no flags provided; use flags to store values in %s, or pass --interactive to open the form", config.GetConfigPath())) @@ -378,6 +457,9 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error { if keychainStored { fmt.Fprintln(out, "Secret credentials stored in OS keychain") } + for _, name := range cleared { + fmt.Fprintf(out, "Cleared --%s\n", name) + } fmt.Fprintf(out, "Configuration saved to %s\n", config.GetConfigPath()) return nil } diff --git a/zSDKs/sdk-cli/internal/cli/whoami.go b/zSDKs/sdk-cli/internal/cli/whoami.go index 7a634a739..54c8b5cac 100644 --- a/zSDKs/sdk-cli/internal/cli/whoami.go +++ b/zSDKs/sdk-cli/internal/cli/whoami.go @@ -20,7 +20,7 @@ func initWhoamiCmd(parent *cobra.Command) error { Sources are shown as: [flag] - Set via command line flag [env] - Set via environment variable (CLI_*) - [keyring] - Set via OS keychain (stored by configure command) + [keyring] - Set via OS credential store: macOS Keychain, Windows Credential Manager or Secret Service (stored by configure command) [config] - Set via config file (~/.config/cli/config.yaml) [default] - Built-in global parameter flag default [unset] - Not configured diff --git a/zSDKs/sdk-cli/internal/config/keyring.go b/zSDKs/sdk-cli/internal/config/keyring.go index 2c632fa8d..315edc9bc 100644 --- a/zSDKs/sdk-cli/internal/config/keyring.go +++ b/zSDKs/sdk-cli/internal/config/keyring.go @@ -170,6 +170,20 @@ func DeleteKeyringValue(key string) error { return withKeyringTimeout(func() error { return backend.Delete(cliName, key) }) } +// DeleteStoredSecret removes a credential from the OS keychain when it is +// available. A credential that is not stored there, or a keychain that stops +// responding (already reported as skipped), is not an error. +func DeleteStoredSecret(key string) error { + if !KeyringAvailable() { + return nil + } + err := DeleteKeyringValue(key) + if err == nil || errors.Is(err, keyring.ErrNotFound) || errors.Is(err, ErrKeyringUnavailable) { + return nil + } + return err +} + // KeyringAvailable returns whether the OS keychain is accessible. // Useful for configure command to decide where to store secrets. func KeyringAvailable() bool {