diff --git a/.changesets/1792716414-5d8f721c.yaml b/.changesets/1792716414-5d8f721c.yaml new file mode 100644 index 000000000..75129f471 --- /dev/null +++ b/.changesets/1792716414-5d8f721c.yaml @@ -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" diff --git a/templates/templates/cli/auth.go.stmpl b/templates/templates/cli/auth.go.stmpl index 0fb46c2af..9e124689f 100644 --- a/templates/templates/cli/auth.go.stmpl +++ b/templates/templates/cli/auth.go.stmpl @@ -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}} @@ -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 { @@ -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.") diff --git a/templates/templates/cli/auxiliary/internal/output/output.go.stmpl b/templates/templates/cli/auxiliary/internal/output/output.go.stmpl index 8cc7561be..50606f364 100644 --- a/templates/templates/cli/auxiliary/internal/output/output.go.stmpl +++ b/templates/templates/cli/auxiliary/internal/output/output.go.stmpl @@ -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 { diff --git a/templates/templates/cli/configure.go.stmpl b/templates/templates/cli/configure.go.stmpl index c5bc9654b..efd84bf46 100644 --- a/templates/templates/cli/configure.go.stmpl +++ b/templates/templates/cli/configure.go.stmpl @@ -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 -}} @@ -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{} @@ -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 { diff --git a/templates/templates/cli/includes/security.ts b/templates/templates/cli/includes/security.ts index b250ed4d5..e511ebbfe 100644 --- a/templates/templates/cli/includes/security.ts +++ b/templates/templates/cli/includes/security.ts @@ -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) { diff --git a/templates/templates/cli/tests/primary/configure_test.go.stmpl b/templates/templates/cli/tests/primary/configure_test.go.stmpl index e425d873c..b425d8757 100644 --- a/templates/templates/cli/tests/primary/configure_test.go.stmpl +++ b/templates/templates/cli/tests/primary/configure_test.go.stmpl @@ -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 diff --git a/zSDKs/sdk-cli/.speakeasy/gen.lock b/zSDKs/sdk-cli/.speakeasy/gen.lock index 9a693a3a0..c87ca125c 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:8c70569b35b5583ca7e8c23402a9e164d98f1d1b + last_write_checksum: sha1:437aab6849f3bb917de447a4568e06217fe302c3 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:66da0edca07db643976427521977046f4bca216c + last_write_checksum: sha1:3f4f3f60afb2647dcd09159d9f3b91894f9441dd internal/cli/createuser.go: last_write_checksum: sha1:5b60000059db85dcf4c90f5e67c37b7fff3cf852 internal/cli/createwithunion.go: @@ -282,7 +282,7 @@ trackedFiles: internal/output/jq.go: last_write_checksum: sha1:9e59e92439a8c6269a76be1fa44e85cbbf2dee59 internal/output/output.go: - last_write_checksum: sha1:b75c9ee7521310aeb70a561d5cfc98bd6d3e9da6 + last_write_checksum: sha1:d57b00cf9e196c416590bb936efb38bc037252e7 internal/output/output_test.go: last_write_checksum: sha1:fc3d380351e2c9e5ffb2bc663e948ab37755e167 internal/output/outputitems.go: diff --git a/zSDKs/sdk-cli/internal/cli/auth.go b/zSDKs/sdk-cli/internal/cli/auth.go index 7e13cc6f2..75247b6b6 100644 --- a/zSDKs/sdk-cli/internal/cli/auth.go +++ b/zSDKs/sdk-cli/internal/cli/auth.go @@ -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. @@ -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") @@ -449,41 +457,42 @@ 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 = "" @@ -491,7 +500,17 @@ 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 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(), + }) + } 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.") diff --git a/zSDKs/sdk-cli/internal/cli/configure.go b/zSDKs/sdk-cli/internal/cli/configure.go index aac3f0a01..3b73c6b85 100644 --- a/zSDKs/sdk-cli/internal/cli/configure.go +++ b/zSDKs/sdk-cli/internal/cli/configure.go @@ -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{} @@ -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") diff --git a/zSDKs/sdk-cli/internal/output/output.go b/zSDKs/sdk-cli/internal/output/output.go index 678c611f3..18281f062 100644 --- a/zSDKs/sdk-cli/internal/output/output.go +++ b/zSDKs/sdk-cli/internal/output/output.go @@ -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 {