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
10 changes: 10 additions & 0 deletions .changesets/1792716414-5d8f721c.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
id: 1792716414-5d8f721c
features:
- core
targets:
- cli
type: fix
bump: patch
description: configure, auth login and auth logout return a structured result in machine output modes instead of plain text
author: TristanSpeakEasy
date: "2026-10-23"
22 changes: 22 additions & 0 deletions templates/templates/cli/auth.go.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
{{- addImport "internal/flagutil" true -}}
{{- if templateHasSecuritySchemeGroups}}{{- addImport "internal/interactive" true -}}{{- addImport "internal/forms" true -}}{{- end}}
{{- addImport "fmt" -}}
{{- if hasSecretCLIFields}}{{- addImport "errors" -}}{{- end}}
{{- addImport "internal/output" true -}}
package cli

{{genImports}}
Expand Down Expand Up @@ -123,6 +125,14 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to save configuration: %w", err)
}

if output.IsMachineMode(cmd) {
return output.LocalResult(cmd, map[string]any{
"config_file": config.GetConfigPath(),
{{- if hasSecretCLIFields}}
"keychain_stored": keychainStored,
{{- end}}
})
}
out := cmd.OutOrStdout()
{{- if hasSecretCLIFields}}
if keychainStored {
Expand All @@ -148,7 +158,19 @@ func runAuthLogoutCmd(cmd *cobra.Command, args []string) error {
if err := config.SaveConfig(cfg); err != nil {
return fmt.Errorf("failed to save configuration: %w", err)
}
{{- if hasSecretCLIFields}}
if err := errors.Join(keychainErrs...); err != nil {
return fmt.Errorf("credentials were cleared from the config file, but removing them from the OS keychain failed: %w", err)
}
{{- end}}

if output.IsMachineMode(cmd) {
return output.LocalResult(cmd, map[string]any{
"config_file": config.GetConfigPath(),
"cleared": true,
"keychain_cleared": config.KeyringAvailable(),
})
}
out := cmd.OutOrStdout()
if !config.KeyringAvailable() {
fmt.Fprintln(out, "Authentication credentials have been cleared from the config file; the OS keychain was skipped, so credentials stored there (if any) were kept.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ func resolveOutputFormat(cmd *cobra.Command) string {
return flagutil.ResolveOutputFormat(cmd, config.GetString("output-format"), IsAgentMode())
}

// PinOutputFormat fixes the output format until the returned func is called,
// so a command that rewrites the saved default still renders in the format it
// started with.
func PinOutputFormat(cmd *cobra.Command) func() {
previous := preparsedRendering.outputFormat
if previous == "" {
preparsedRendering.outputFormat = resolveOutputFormat(cmd)
}
return func() { preparsedRendering.outputFormat = previous }
}

// A config file value is a saved default, not a per-invocation choice, so it
// never counts as explicit even though resolveOutputFormat still honours it.
func outputFormatExplicit(cmd *cobra.Command) bool {
Expand Down
16 changes: 15 additions & 1 deletion templates/templates/cli/configure.go.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
{{- addImport "internal/usage" true -}}
{{- if isAnyInteractiveEnabled -}}
{{- addImport "internal/interactive" true -}}
{{- addImport "internal/output" true -}}
{{- addImport "strings" -}}
{{- end -}}
{{- addImport "internal/output" true -}}
{{- addImport "fmt" -}}
{{- addImport "encoding/json" -}}
{{- if isInteractiveAuthEnabled -}}
Expand Down Expand Up @@ -54,6 +54,8 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error {
if dryRunLocalNoop(cmd, "configure changes local settings only (no API request); nothing was changed.") {
return nil
}
defer output.PinOutputFormat(cmd)()
machineOutput := output.IsMachineMode(cmd)
cfg := config.GetConfig()
if cfg == nil {
cfg = &config.Config{}
Expand Down Expand Up @@ -171,6 +173,18 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to save configuration: %w", err)
}

if machineOutput {
result := map[string]any{"config_file": config.GetConfigPath()}
{{- if hasSecretCLIFields}}
result["keychain_stored"] = keychainStored
{{- end}}
{{- if isAnyInteractiveEnabled}}
if len(cleared) > 0 {
result["cleared"] = cleared
}
{{- end}}
return output.LocalResult(cmd, result)
}
out{{if isAnyInteractiveEnabled}} :{{end}}= cmd.OutOrStdout()
{{- if hasSecretCLIFields}}
if keychainStored {
Expand Down
11 changes: 9 additions & 2 deletions templates/templates/cli/includes/security.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2387,14 +2387,21 @@ function templateAuthLogoutClear(): string {

const acc = securityConfigAccessor();
const lines: string[] = [];
if (fields.some((f) => f.isSecret)) {
lines.push(`var keychainErrs []error`);
}

for (const field of fields) {
const fieldName = caser().ToPascal(field.field.Name);
const cfgField = `${acc}${fieldName}`;

if (field.isSecret) {
lines.push(`if config.KeyringAvailable() {`);
lines.push(` _ = config.DeleteKeyringValue("${field.flagName}")`);
lines.push(
`if err := config.DeleteStoredSecret("${field.flagName}"); err != nil {`,
);
lines.push(
` keychainErrs = append(keychainErrs, fmt.Errorf("--${field.flagName}: %w", err))`,
);
lines.push(`}`);
}
if (field.isArray) {
Expand Down
64 changes: 64 additions & 0 deletions templates/templates/cli/tests/primary/configure_test.go.stmpl
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,70 @@ func TestConfigureClearReportsKeychainDeleteFailure(t *testing.T) {
assert.Equal(t, 1, backend.deletes)
}

func TestConfigureClearMachineOutput(t *testing.T) {
h, home := setupConfigureTest(t)
config.SetKeyringBackend(&recordingKeyring{})
t.Cleanup(config.ResetKeyring)
writeConfigFile(t, home, map[string]interface{}{
"globals": map[string]interface{}{"global_query_param": "stored-query"},
"output_format": "json",
})
rootCmd, err := cli.NewRootCommand()
require.NoError(t, err)
rootCmd.SetOut(h.stdout)
rootCmd.SetErr(h.stderr)
require.NoError(t, cli.ExecuteRoot(context.Background(), rootCmd, []string{
"configure", "--no-interactive", "--clear", "global-query-param,default-output-format",
}))
var result struct {
ConfigFile string `json:"config_file"`
Cleared []string `json:"cleared"`
}
require.NoError(t, json.Unmarshal([]byte(h.GetStdout()), &result), "stdout: %s", h.GetStdout())
assert.Equal(t, cliConfigPath(home), result.ConfigFile)
assert.Equal(t, []string{"global-query-param", "default-output-format"}, result.Cleared)
}

func TestAuthLogoutMachineOutput(t *testing.T) {
h, home := setupConfigureTest(t)
config.SetKeyringBackend(&recordingKeyring{})
t.Cleanup(config.ResetKeyring)
writeConfigFile(t, home, map[string]interface{}{
"security": map[string]interface{}{"api_key_auth": "stored-key"},
})
rootCmd, err := cli.NewRootCommand()
require.NoError(t, err)
rootCmd.SetOut(h.stdout)
rootCmd.SetErr(h.stderr)
require.NoError(t, cli.ExecuteRoot(context.Background(), rootCmd, []string{"auth", "logout", "--output-format", "json"}))
var result struct {
Cleared bool `json:"cleared"`
KeychainCleared bool `json:"keychain_cleared"`
}
require.NoError(t, json.Unmarshal([]byte(h.GetStdout()), &result), "stdout: %s", h.GetStdout())
assert.True(t, result.Cleared)
assert.True(t, result.KeychainCleared)
}

func TestAuthLogoutReportsKeychainDeleteFailure(t *testing.T) {
h, home := setupConfigureTest(t)
config.SetKeyringBackend(&deleteFailingKeyring{})
t.Cleanup(config.ResetKeyring)
writeConfigFile(t, home, map[string]interface{}{
"security": map[string]interface{}{"api_key_auth": "stored-key"},
})
rootCmd, err := cli.NewRootCommand()
require.NoError(t, err)
rootCmd.SetOut(h.stdout)
rootCmd.SetErr(h.stderr)
err = cli.ExecuteRoot(context.Background(), rootCmd, []string{"auth", "logout"})
require.Error(t, err)
assert.Contains(t, err.Error(), "removing them from the OS keychain failed")
data, readErr := os.ReadFile(cliConfigPath(home))
require.NoError(t, readErr)
assert.NotContains(t, string(data), "stored-key")
}

func TestLocalMutationCommandsDryRunAreNoOps(t *testing.T) {
for _, tt := range []struct {
name string
Expand Down
6 changes: 3 additions & 3 deletions zSDKs/sdk-cli/.speakeasy/gen.lock

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

55 changes: 37 additions & 18 deletions zSDKs/sdk-cli/internal/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ package cli

import (
"cmp"
"errors"
"fmt"
"github.com/spf13/cobra"
"openapi/internal/config"
"openapi/internal/flagutil"
"openapi/internal/forms"
"openapi/internal/interactive"
"openapi/internal/output"
)

// initAuthCmd registers the auth command group with login, whoami, and logout subcommands.
Expand Down Expand Up @@ -431,6 +433,12 @@ func runAuthLoginCmd(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to save configuration: %w", err)
}

if output.IsMachineMode(cmd) {
return output.LocalResult(cmd, map[string]any{
"config_file": config.GetConfigPath(),
"keychain_stored": keychainStored,
})
}
out := cmd.OutOrStdout()
if keychainStored {
fmt.Fprintln(out, "Secret credentials stored in OS keychain")
Expand All @@ -449,49 +457,60 @@ func runAuthLogoutCmd(cmd *cobra.Command, args []string) error {
cfg = &config.Config{}
}

var keychainErrs []error
cfg.Security.Username = ""
if config.KeyringAvailable() {
_ = config.DeleteKeyringValue("password")
if err := config.DeleteStoredSecret("password"); err != nil {
keychainErrs = append(keychainErrs, fmt.Errorf("--password: %w", err))
}
cfg.Security.Password = ""
if config.KeyringAvailable() {
_ = config.DeleteKeyringValue("bearer-auth")
if err := config.DeleteStoredSecret("bearer-auth"); err != nil {
keychainErrs = append(keychainErrs, fmt.Errorf("--bearer-auth: %w", err))
}
cfg.Security.BearerAuth = ""
if config.KeyringAvailable() {
_ = config.DeleteKeyringValue("my-api-key")
if err := config.DeleteStoredSecret("my-api-key"); err != nil {
keychainErrs = append(keychainErrs, fmt.Errorf("--my-api-key: %w", err))
}
cfg.Security.MyApiKey = ""
if config.KeyringAvailable() {
_ = config.DeleteKeyringValue("oauth2")
if err := config.DeleteStoredSecret("oauth2"); err != nil {
keychainErrs = append(keychainErrs, fmt.Errorf("--oauth2: %w", err))
}
cfg.Security.Oauth2 = ""
if config.KeyringAvailable() {
_ = config.DeleteKeyringValue("app-id")
if err := config.DeleteStoredSecret("app-id"); err != nil {
keychainErrs = append(keychainErrs, fmt.Errorf("--app-id: %w", err))
}
cfg.Security.AppId = ""
if config.KeyringAvailable() {
_ = config.DeleteKeyringValue("secret")
if err := config.DeleteStoredSecret("secret"); err != nil {
keychainErrs = append(keychainErrs, fmt.Errorf("--secret: %w", err))
}
cfg.Security.Secret = ""
if config.KeyringAvailable() {
_ = config.DeleteKeyringValue("mobile-auth")
if err := config.DeleteStoredSecret("mobile-auth"); err != nil {
keychainErrs = append(keychainErrs, fmt.Errorf("--mobile-auth: %w", err))
}
cfg.Security.MobileAuth = ""
if config.KeyringAvailable() {
_ = config.DeleteKeyringValue("client-id")
if err := config.DeleteStoredSecret("client-id"); err != nil {
keychainErrs = append(keychainErrs, fmt.Errorf("--client-id: %w", err))
}
cfg.Security.ClientID = ""
if config.KeyringAvailable() {
_ = config.DeleteKeyringValue("client-secret")
if err := config.DeleteStoredSecret("client-secret"); err != nil {
keychainErrs = append(keychainErrs, fmt.Errorf("--client-secret: %w", err))
}
cfg.Security.ClientSecret = ""
cfg.Security.TokenURL = ""

if err := config.SaveConfig(cfg); err != nil {
return fmt.Errorf("failed to save configuration: %w", err)
}
if err := errors.Join(keychainErrs...); err != nil {
return fmt.Errorf("credentials were cleared from the config file, but removing them from the OS keychain failed: %w", err)
}

if output.IsMachineMode(cmd) {
return output.LocalResult(cmd, map[string]any{
"config_file": config.GetConfigPath(),
"cleared": true,
"keychain_cleared": config.KeyringAvailable(),
Comment thread
TristanSpeakEasy marked this conversation as resolved.
})
}
out := cmd.OutOrStdout()
if !config.KeyringAvailable() {
fmt.Fprintln(out, "Authentication credentials have been cleared from the config file; the OS keychain was skipped, so credentials stored there (if any) were kept.")
Expand Down
10 changes: 10 additions & 0 deletions zSDKs/sdk-cli/internal/cli/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error {
if dryRunLocalNoop(cmd, "configure changes local settings only (no API request); nothing was changed.") {
return nil
}
defer output.PinOutputFormat(cmd)()
machineOutput := output.IsMachineMode(cmd)
cfg := config.GetConfig()
if cfg == nil {
cfg = &config.Config{}
Expand Down Expand Up @@ -453,6 +455,14 @@ func runConfigureCmd(cmd *cobra.Command, args []string) error {
return fmt.Errorf("failed to save configuration: %w", err)
}

if machineOutput {
result := map[string]any{"config_file": config.GetConfigPath()}
result["keychain_stored"] = keychainStored
if len(cleared) > 0 {
result["cleared"] = cleared
}
return output.LocalResult(cmd, result)
}
out := cmd.OutOrStdout()
if keychainStored {
fmt.Fprintln(out, "Secret credentials stored in OS keychain")
Expand Down
11 changes: 11 additions & 0 deletions zSDKs/sdk-cli/internal/output/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,17 @@ func resolveOutputFormat(cmd *cobra.Command) string {
return flagutil.ResolveOutputFormat(cmd, config.GetString("output-format"), IsAgentMode())
}

// PinOutputFormat fixes the output format until the returned func is called,
// so a command that rewrites the saved default still renders in the format it
// started with.
func PinOutputFormat(cmd *cobra.Command) func() {
previous := preparsedRendering.outputFormat
if previous == "" {
preparsedRendering.outputFormat = resolveOutputFormat(cmd)
}
return func() { preparsedRendering.outputFormat = previous }
}

// A config file value is a saved default, not a per-invocation choice, so it
// never counts as explicit even though resolveOutputFormat still honours it.
func outputFormatExplicit(cmd *cobra.Command) bool {
Expand Down
Loading