Skip to content
Open
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
12 changes: 12 additions & 0 deletions .changesets/1792716255-70167bd5.yaml
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 3 additions & 1 deletion templates/templates/cli/auth.go.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
9 changes: 8 additions & 1 deletion templates/templates/cli/configure.go.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}}
Expand Down Expand Up @@ -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
}
Expand Down
74 changes: 74 additions & 0 deletions templates/templates/cli/includes/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2721,6 +2721,8 @@ function templateConfigureNonInteractiveStore(): string {
}
}

lines.push(...genConfigureClear());

lines.push(``);
lines.push(`if !changed {`);
lines.push(
Expand All @@ -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,
Expand Down Expand Up @@ -2763,6 +2836,7 @@ function templateConfigureFlagNames(): string {
return goFlagNameArgs([
...storedFlagNames("configure"),
DEFAULT_OUTPUT_FORMAT_FLAG,
CONFIGURE_CLEAR_FLAG,
]);
}
registerTemplateFunc("templateConfigureFlagNames", templateConfigureFlagNames);
Expand Down
54 changes: 54 additions & 0 deletions templates/templates/cli/tests/primary/configure_test.go.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tests
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion templates/templates/cli/whoami.go.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions zSDKs/sdk-cli/.speakeasy/gen.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion zSDKs/sdk-cli/docs/cli_auth_whoami.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions zSDKs/sdk-cli/docs/cli_configure.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion zSDKs/sdk-cli/docs/cli_whoami.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion zSDKs/sdk-cli/internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading
Loading