From 1dad2112a9a41d945d7183ac3628958e142ba558 Mon Sep 17 00:00:00 2001 From: Ross Nelson Date: Wed, 8 Jul 2026 11:55:19 -0400 Subject: [PATCH 1/9] Add staged connection diagnosis for dial failures Connection failures now render a classified error with an inline DNS/TCP/TLS diagnosis and one suggested fix, instead of a bare 'context deadline exceeded' or an empty message. Fixes #224 Fixes #851 --- cliext/client.go | 30 +++ internal/temporalcli/client.go | 63 ++++- internal/temporalcli/client_test.go | 205 ++++++++++++++- internal/temporalcli/commands.go | 11 +- internal/temporalcli/connectdiag.go | 268 +++++++++++++++++++ internal/temporalcli/connectdiag_test.go | 311 +++++++++++++++++++++++ internal/temporalcli/connecterror.go | 216 ++++++++++++++++ 7 files changed, 1096 insertions(+), 8 deletions(-) create mode 100644 internal/temporalcli/connectdiag.go create mode 100644 internal/temporalcli/connectdiag_test.go create mode 100644 internal/temporalcli/connecterror.go diff --git a/cliext/client.go b/cliext/client.go index 48eaa7508..e723c1d73 100644 --- a/cliext/client.go +++ b/cliext/client.go @@ -2,7 +2,9 @@ package cliext import ( "context" + "errors" "fmt" + "io/fs" "log/slog" "net/http" "strings" @@ -34,6 +36,19 @@ type ClientOptionsBuilder struct { // configured. Callers can use it to decode payloads outside the gRPC // interceptor chain (e.g. payloads nested inside opaque proto bytes). PayloadCodec converter.PayloadCodec + + // ResolvedAddress is populated by Build with the final server address, + // after profile resolution and namespace templating. + ResolvedAddress string + // ResolvedAddressSource is populated by Build with where the final address + // came from: "flag", "profile", or "default". + ResolvedAddressSource string + // ResolvedProfileName is populated by Build with the config profile name + // used, if any. + ResolvedProfileName string + // HasAPIKey is populated by Build and reports whether API-key credentials + // are configured (via flag, profile, or OAuth). + HasAPIKey bool } type oauthCredentials struct { @@ -108,8 +123,12 @@ func (b *ClientOptionsBuilder) Build(ctx context.Context) (client.Options, error // override the profile version unless it was _explicitly_ set. if cfg.FlagSet != nil && cfg.FlagSet.Changed("address") { profile.Address = cfg.Address + b.ResolvedAddressSource = "flag" } else if profile.Address == "" { profile.Address = cfg.Address + b.ResolvedAddressSource = "default" + } else { + b.ResolvedAddressSource = "profile" } var namespaceExplicitlySet bool if cfg.FlagSet != nil && cfg.FlagSet.Changed("namespace") { @@ -126,6 +145,7 @@ func (b *ClientOptionsBuilder) Build(ctx context.Context) (client.Options, error if addressHasNamespaceTemplate { profile.Address = strings.ReplaceAll(profile.Address, addressNamespaceTemplate, profile.Namespace) } + b.ResolvedAddress = profile.Address // Set API key on profile if provided if cfg.ApiKey != "" { @@ -204,9 +224,18 @@ func (b *ClientOptionsBuilder) Build(ctx context.Context) (client.Options, error profile.Codec.Auth = cfg.CodecAuth } + b.ResolvedProfileName = common.Profile + b.HasAPIKey = profile.APIKey != "" + // Convert profile to client options. clientOpts, err := profile.ToClientOptions(envconfig.ToClientOptionsRequest{}) if err != nil { + // File-path errors (e.g. an unreadable TLS cert) are common enough to + // deserve naming the offending file. + var pathErr *fs.PathError + if errors.As(err, &pathErr) { + return client.Options{}, fmt.Errorf("failed to build client options: cannot read file %q: %w", pathErr.Path, err) + } return client.Options{}, fmt.Errorf("failed to build client options: %w", err) } @@ -249,6 +278,7 @@ func (b *ClientOptionsBuilder) Build(ctx context.Context) (client.Options, error profileName: result.ProfileName, } clientOpts.Credentials = client.NewAPIKeyDynamicCredentials(creds.getToken) + b.HasAPIKey = true } } diff --git a/internal/temporalcli/client.go b/internal/temporalcli/client.go index 230ff5a35..81dfe97c7 100644 --- a/internal/temporalcli/client.go +++ b/internal/temporalcli/client.go @@ -2,7 +2,9 @@ package temporalcli import ( "context" + "errors" "fmt" + "io/fs" "os" "os/user" @@ -56,6 +58,17 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client. } clientOpts, err := builder.Build(cctx) if err != nil { + // An unreadable TLS/credential file is a connection-setup problem + // worth a suggestion, even though no dial happened. + var pathErr *fs.PathError + if errors.As(err, &pathErr) { + diag := &connectDiagnosis{ + Address: builder.ResolvedAddress, + Cause: causeCertFileUnreadable, + Detail: pathErr.Path, + } + return nil, nil, newConnectError(diag, connectMetaFromBuilder(cctx, builder), err) + } return nil, nil, err } @@ -87,7 +100,7 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client. cl, err := client.DialContext(dialCtx, clientOpts) if err != nil { - return nil, nil, err + return nil, nil, dialConnectError(cctx, dialCtx, builder, clientOpts, err) } // Since this namespace value is used by many commands after this call, @@ -97,6 +110,54 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client. return cl, builder.PayloadCodec, nil } +// dialConnectError enriches a client.DialContext failure with a staged +// connection diagnosis and a suggested fix. See connectdiag.go. +func dialConnectError( + cctx *CommandContext, + dialCtx context.Context, + builder *cliext.ClientOptionsBuilder, + clientOpts client.Options, + origErr error, +) error { + // If a CLI-side timeout fired, surface its descriptive cause, which is + // otherwise lost ("context deadline exceeded" wins over "command timed + // out after 5s"). + if dialCtx.Err() != nil { + if cause := context.Cause(dialCtx); cause != nil && !errors.Is(cause, dialCtx.Err()) { + origErr = fmt.Errorf("%w (%v)", origErr, cause) + } + } + // Never probe after an interrupt or when the whole command timed out. + if cctx.Err() != nil { + return origErr + } + if v, _ := cctx.Options.EnvLookup.LookupEnv("TEMPORAL_CLI_DISABLE_CONNECT_DIAGNOSIS"); v != "" { + return fmt.Errorf("failed connecting to Temporal server at %v: %w", clientOpts.HostPort, origErr) + } + // The probe has its own internal budget, but never let it exceed the + // user's explicit connect timeout. + probeCtx := context.Context(cctx) + if timeout := cctx.RootCommand.CommonOptions.ClientConnectTimeout.Duration(); timeout > 0 { + var cancel context.CancelFunc + probeCtx, cancel = context.WithTimeout(cctx, timeout) + defer cancel() + } + diag := diagnoseConnection(probeCtx, clientOpts.HostPort, clientOpts.ConnectionOptions.TLS, origErr) + meta := connectMetaFromBuilder(cctx, builder) + meta.TLSConfigured = clientOpts.ConnectionOptions.TLS != nil + return newConnectError(diag, meta, origErr) +} + +func connectMetaFromBuilder(cctx *CommandContext, builder *cliext.ClientOptionsBuilder) connectMeta { + return connectMeta{ + Args: cctx.Options.Args, + Address: builder.ResolvedAddress, + AddressSource: builder.ResolvedAddressSource, + ProfileName: builder.ResolvedProfileName, + HasAPIKey: builder.HasAPIKey, + } +} + func fixedHeaderOverrideInterceptor( ctx context.Context, method string, req, reply any, diff --git a/internal/temporalcli/client_test.go b/internal/temporalcli/client_test.go index 2aa7ae391..6593e57b5 100644 --- a/internal/temporalcli/client_test.go +++ b/internal/temporalcli/client_test.go @@ -1,6 +1,15 @@ package temporalcli_test import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" "net" "testing" "time" @@ -9,11 +18,13 @@ import ( "github.com/stretchr/testify/require" ) -func TestClientConnectTimeout(t *testing.T) { - // Start a listener that accepts connections but never responds +// startBlackHoleListener starts a listener that accepts connections but never +// responds. +func startBlackHoleListener(t *testing.T) net.Listener { + t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) - defer ln.Close() + t.Cleanup(func() { ln.Close() }) go func() { for { conn, err := ln.Accept() @@ -24,6 +35,62 @@ func TestClientConnectTimeout(t *testing.T) { select {} } }() + return ln +} + +// startMTLSListener starts a TLS listener that requires client certificates, +// returning its address and the server CA cert as PEM. +func startMTLSListener(t *testing.T) (addr, caPEM string) { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "client-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + require.NoError(t, err) + leaf, err := x509.ParseCertificate(der) + require.NoError(t, err) + pool := x509.NewCertPool() + pool.AddCert(leaf) + + ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: pool, + }) + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func() { + if tlsConn, ok := conn.(*tls.Conn); ok { + _ = tlsConn.HandshakeContext(context.Background()) + buf := make([]byte, 1) + _ = tlsConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _ = tlsConn.Read(buf) + } + conn.Close() + }() + } + }() + return ln.Addr().String(), string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +} + +func TestClientConnectTimeout(t *testing.T) { + ln := startBlackHoleListener(t) h := NewCommandHarness(t) @@ -36,6 +103,136 @@ func TestClientConnectTimeout(t *testing.T) { elapsed := time.Since(start) require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "failed connecting to Temporal server at") assert.Contains(t, res.Err.Error(), "deadline exceeded") - assert.Less(t, elapsed, time.Second, "dial should have timed out quickly") + // The friendly cause attached to the connect timeout must survive. + assert.Contains(t, res.Err.Error(), "command timed out after 50ms") + assert.Less(t, elapsed, 2*time.Second, "dial and diagnosis should respect the connect timeout") +} + +func TestConnectDiagnosis_MTLSRequired(t *testing.T) { + addr, caPEM := startMTLSListener(t) + + h := NewCommandHarness(t) + res := h.Execute( + "workflow", "list", + "--address", addr, + "--tls", + "--tls-ca-data", caPEM, + "--client-connect-timeout", "2s", + ) + + require.Error(t, res.Err) + msg := res.Err.Error() + assert.Contains(t, msg, "Connecting to "+addr) + assert.Contains(t, msg, "✓ TCP connection established") + assert.Contains(t, msg, "✗ TLS handshake failed: server requires mTLS") + assert.Contains(t, msg, "--tls-cert-path YourCert.pem") + assert.Contains(t, msg, "--tls-key-path YourKey.pem") +} + +func TestConnectDiagnosis_Refused(t *testing.T) { + // Grab a port and close it so nothing is listening. + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + require.NoError(t, ln.Close()) + + h := NewCommandHarness(t) + res := h.Execute( + "workflow", "list", + "--address", addr, + "--client-connect-timeout", "2s", + ) + + require.Error(t, res.Err) + msg := res.Err.Error() + assert.Contains(t, msg, "connection refused") + assert.Contains(t, msg, "✗ TCP connection refused") + assert.Contains(t, msg, "Nothing is listening at "+addr) +} + +func TestConnectDiagnosis_DNSFailure(t *testing.T) { + h := NewCommandHarness(t) + res := h.Execute( + "workflow", "list", + // .invalid is reserved (RFC 2606) and can never resolve. + "--address", "does-not-exist.invalid:7233", + "--client-connect-timeout", "5s", + ) + + require.Error(t, res.Err) + msg := res.Err.Error() + assert.Contains(t, msg, "could not resolve host") + assert.Contains(t, msg, "✗ DNS lookup") + assert.Contains(t, msg, `Could not resolve "does-not-exist.invalid"`) +} + +func TestConnectDiagnosis_JSONOutputHasNoANSI(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + require.NoError(t, ln.Close()) + + h := NewCommandHarness(t) + res := h.Execute( + "workflow", "list", + "--address", addr, + "--client-connect-timeout", "2s", + "-o", "json", + ) + + require.Error(t, res.Err) + assert.NotContains(t, res.Err.Error(), "\x1b[", "diagnosis must not contain ANSI escapes in JSON mode") + assert.Empty(t, res.Stdout.String(), "connection failures must not write to stdout") +} + +func TestConnectDiagnosis_Disabled(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + require.NoError(t, ln.Close()) + + h := NewCommandHarness(t) + h.Options.EnvLookup = EnvLookupMap{"TEMPORAL_CLI_DISABLE_CONNECT_DIAGNOSIS": "1"} + res := h.Execute( + "workflow", "list", + "--address", addr, + "--client-connect-timeout", "2s", + ) + + require.Error(t, res.Err) + msg := res.Err.Error() + assert.Contains(t, msg, "failed connecting to Temporal server at "+addr) + assert.NotContains(t, msg, "✗", "diagnosis must be suppressed when disabled") +} + +func TestConnectDiagnosis_CertFileMissing(t *testing.T) { + h := NewCommandHarness(t) + res := h.Execute( + "workflow", "list", + "--address", "127.0.0.1:7233", + "--tls-cert-path", "/definitely/does/not/exist.pem", + "--tls-key-path", "/definitely/does/not/exist.key", + ) + + require.Error(t, res.Err) + msg := res.Err.Error() + assert.Contains(t, msg, "cannot read file") + assert.Contains(t, msg, "/definitely/does/not/exist") + assert.NotContains(t, msg, "✓", "no probe stages expected when the dial never happened") +} + +func TestConnectDiagnosis_CommandTimeoutCauseSurvives(t *testing.T) { + ln := startBlackHoleListener(t) + + h := NewCommandHarness(t) + res := h.Execute( + "workflow", "list", + "--address", ln.Addr().String(), + "--command-timeout", "1s", + ) + + require.Error(t, res.Err) + assert.Contains(t, res.Err.Error(), "command timed out after 1s") } diff --git a/internal/temporalcli/commands.go b/internal/temporalcli/commands.go index ecb52515d..df5fcdb19 100644 --- a/internal/temporalcli/commands.go +++ b/internal/temporalcli/commands.go @@ -140,10 +140,15 @@ func (c *CommandContext) preprocessOptions() error { // Setup default fail callback if c.Options.Fail == nil { c.Options.Fail = func(err error) { - // If context is closed, say that the program was interrupted and ignore - // the actual error + // If context is closed, say that the program was interrupted and + // ignore the actual error — unless the context carries a + // descriptive cause such as a --command-timeout expiry. if c.Err() != nil { - err = fmt.Errorf("program interrupted") + if cause := context.Cause(c); cause != nil && !errors.Is(cause, context.Canceled) { + err = cause + } else { + err = fmt.Errorf("program interrupted") + } } fmt.Fprintf(c.Options.Stderr, "Error: %v\n", err) os.Exit(1) diff --git a/internal/temporalcli/connectdiag.go b/internal/temporalcli/connectdiag.go new file mode 100644 index 000000000..e6eda32a7 --- /dev/null +++ b/internal/temporalcli/connectdiag.go @@ -0,0 +1,268 @@ +package temporalcli + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net" + "strings" + "syscall" + "time" + + "go.temporal.io/api/serviceerror" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// connectCause classifies why a connection to the server failed. It drives +// the suggested fix shown alongside the staged diagnosis. +type connectCause int + +const ( + causeUnknown connectCause = iota + causeDNS + causeTCPRefused + causeTCPTimeout + // causeServerPlaintext means TLS was configured but the server responded + // with a non-TLS handshake. + causeServerPlaintext + // causeServerSpeaksTLS means TLS was not configured but the server + // completed a TLS handshake when offered one. + causeServerSpeaksTLS + causeClientCertRequired + causeCAVerify + causeHostnameMismatch + // causeCertFileUnreadable is set by the caller when building client + // options fails on a file read; the probe never runs for it. + causeCertFileUnreadable + causeAuth + causeTimeout +) + +type diagStatus int + +const ( + diagOK diagStatus = iota + diagFail +) + +type diagStage struct { + Status diagStatus + Label string +} + +// connectDiagnosis is the result of probing a failed connection. +type connectDiagnosis struct { + Address string + Stages []diagStage + Cause connectCause + // Detail carries cause-specific info (e.g. an unreadable file path or the + // raw TLS alert text) for use in stage labels and suggestions. + Detail string +} + +const ( + connectDiagnosisBudget = 3 * time.Second + connectDiagnosisReadProbe = 500 * time.Millisecond +) + +// diagnoseConnection probes address in stages (DNS, TCP, TLS) to pinpoint why +// a dial failed, and classifies origErr for anything past the transport. It +// must only be called on an already-failed dial: it makes fresh network +// connections (including, when TLS is not configured, one anonymous TLS +// handshake to detect a TLS-only server, which may appear in server logs). +func diagnoseConnection(ctx context.Context, address string, tlsCfg *tls.Config, origErr error) *connectDiagnosis { + d := &connectDiagnosis{Address: address, Cause: causeUnknown} + ctx, cancel := context.WithTimeout(ctx, connectDiagnosisBudget) + defer cancel() + + host, _, err := net.SplitHostPort(address) + if err != nil { + // Not a host:port we can probe; fall back to classifying the original + // error only. + d.Cause, d.Detail = classifyGRPCError(origErr) + return d + } + + // Stage: DNS (skipped for IP literals) + if net.ParseIP(host) == nil { + addrs, err := net.DefaultResolver.LookupHost(ctx, host) + if err != nil { + d.fail(fmt.Sprintf("DNS lookup for %q failed: %v", host, dnsErrShort(err))) + d.Cause = causeDNS + return d + } + plural := "es" + if len(addrs) == 1 { + plural = "" + } + d.ok(fmt.Sprintf("DNS resolved (%d address%s)", len(addrs), plural)) + } + + // Stage: TCP + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", address) + if err != nil { + if errors.Is(err, syscall.ECONNREFUSED) { + d.fail("TCP connection refused: nothing is listening at " + address) + d.Cause = causeTCPRefused + } else if isTimeout(err) { + d.fail("TCP connection timed out") + d.Cause = causeTCPTimeout + } else { + d.fail(fmt.Sprintf("TCP connection failed: %v", err)) + d.Cause = causeTCPTimeout + } + return d + } + defer conn.Close() + d.ok("TCP connection established") + + if tlsCfg != nil { + d.probeTLS(ctx, conn, host, tlsCfg) + if d.Cause != causeUnknown { + return d + } + } else if cause, detail := probeServerSpeaksTLS(ctx, conn, host); cause != causeUnknown { + d.fail("server expects TLS, but the CLI is connecting without it" + detail) + d.Cause = cause + return d + } + + // Stage: gRPC — no re-dial; classify the original error. + d.Cause, d.Detail = classifyGRPCError(origErr) + d.fail("gRPC connection failed: " + shortErr(origErr)) + return d +} + +// probeTLS performs a TLS handshake over conn using the client's own config +// and classifies the failure, if any. On handshake success it briefly reads to +// catch post-handshake rejections: TLS 1.3 servers requiring client +// certificates complete the handshake first and only then send an alert. +func (d *connectDiagnosis) probeTLS(ctx context.Context, conn net.Conn, host string, tlsCfg *tls.Config) { + cfg := tlsCfg.Clone() + if cfg.ServerName == "" && !cfg.InsecureSkipVerify { + cfg.ServerName = host + } + tlsConn := tls.Client(conn, cfg) + if err := tlsConn.HandshakeContext(ctx); err != nil { + d.classifyTLSError(err) + return + } + // Handshake OK; a short read distinguishes an mTLS rejection alert from a + // genuinely healthy connection (where the read just times out). + _ = tlsConn.SetReadDeadline(time.Now().Add(connectDiagnosisReadProbe)) + buf := make([]byte, 1) + _, err := tlsConn.Read(buf) + if err != nil && !isTimeout(err) { + if cause := classifyTLSAlert(err); cause != causeUnknown { + d.classifyTLSError(err) + return + } + } + d.ok("TLS handshake succeeded") +} + +func (d *connectDiagnosis) classifyTLSError(err error) { + var recordErr tls.RecordHeaderError + var certErr *tls.CertificateVerificationError + var unknownAuthErr x509.UnknownAuthorityError + var hostnameErr x509.HostnameError + switch { + case errors.As(err, &recordErr): + d.fail("TLS handshake failed: server did not respond with TLS (it may be a plaintext gRPC endpoint)") + d.Cause = causeServerPlaintext + case errors.As(err, &hostnameErr): + d.fail("TLS handshake failed: server certificate is not valid for this host: " + shortErr(err)) + d.Cause = causeHostnameMismatch + case errors.As(err, &unknownAuthErr), errors.As(err, &certErr): + d.fail("TLS handshake failed: cannot verify server certificate: " + shortErr(err)) + d.Cause = causeCAVerify + case classifyTLSAlert(err) == causeClientCertRequired: + d.fail("TLS handshake failed: server requires mTLS, no valid client certificate was provided") + d.Cause = causeClientCertRequired + default: + d.fail("TLS handshake failed: " + shortErr(err)) + d.Cause = causeUnknown + d.Detail = err.Error() + } +} + +// classifyTLSAlert detects remote TLS alerts that indicate the server wants a +// (different) client certificate. Go does not export alert types for remote +// errors, so this matches the alert descriptions crypto/tls emits: alert 116 +// "certificate required" (TLS 1.3), alert 42 "bad certificate", and alert 40 +// "handshake failure" (how TLS 1.2 servers commonly reject missing client +// certs). Matching is scoped to TLS probe errors only; if these strings drift +// in a future Go release, unit tests pin them and the diagnosis degrades to +// showing the raw error without a suggestion. +func classifyTLSAlert(err error) connectCause { + msg := err.Error() + if strings.Contains(msg, "certificate required") || + strings.Contains(msg, "bad certificate") || + strings.Contains(msg, "handshake failure") { + return causeClientCertRequired + } + return causeUnknown +} + +// probeServerSpeaksTLS opportunistically offers a TLS handshake to a server +// the CLI is configured to reach in plaintext. If the server negotiates TLS +// (or rejects us at the certificate step, which still means it spoke TLS), the +// mismatch is the likely root cause. +func probeServerSpeaksTLS(ctx context.Context, conn net.Conn, host string) (connectCause, string) { + tlsConn := tls.Client(conn, &tls.Config{InsecureSkipVerify: true, ServerName: host}) + err := tlsConn.HandshakeContext(ctx) + if err == nil { + return causeServerSpeaksTLS, "" + } + if classifyTLSAlert(err) == causeClientCertRequired { + return causeServerSpeaksTLS, " (and it requires client certificates)" + } + return causeUnknown, "" +} + +func classifyGRPCError(err error) (connectCause, string) { + var deadlineErr *serviceerror.DeadlineExceeded + if errors.As(err, &deadlineErr) || errors.Is(err, context.DeadlineExceeded) { + return causeTimeout, "" + } + if unwrapped := errors.Unwrap(err); unwrapped != nil { + if st, ok := status.FromError(unwrapped); ok { + switch st.Code() { + case codes.Unauthenticated, codes.PermissionDenied: + return causeAuth, st.Message() + case codes.DeadlineExceeded: + return causeTimeout, "" + } + } + } + return causeUnknown, "" +} + +func (d *connectDiagnosis) ok(label string) { d.Stages = append(d.Stages, diagStage{diagOK, label}) } +func (d *connectDiagnosis) fail(label string) { + d.Stages = append(d.Stages, diagStage{diagFail, label}) +} + +func isTimeout(err error) bool { + var netErr net.Error + return errors.Is(err, context.DeadlineExceeded) || + (errors.As(err, &netErr) && netErr.Timeout()) +} + +// shortErr renders an error compactly for a stage line, trimming the SDK's +// "failed reaching server:" prefix that would be redundant inside a +// connection diagnosis. +func shortErr(err error) string { + return strings.TrimPrefix(err.Error(), "failed reaching server: ") +} + +func dnsErrShort(err error) string { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return "no such host" + } + return err.Error() +} diff --git a/internal/temporalcli/connectdiag_test.go b/internal/temporalcli/connectdiag_test.go new file mode 100644 index 000000000..5b10e8f1d --- /dev/null +++ b/internal/temporalcli/connectdiag_test.go @@ -0,0 +1,311 @@ +package temporalcli + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "fmt" + "math/big" + "net" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.temporal.io/api/serviceerror" +) + +// newTestCert generates a self-signed server certificate for 127.0.0.1. +func newTestCert(t *testing.T) tls.Certificate { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "connectdiag-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + DNSNames: []string{"localhost"}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + IsCA: true, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + require.NoError(t, err) + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key, Leaf: mustParseCert(t, der)} +} + +func mustParseCert(t *testing.T, der []byte) *x509.Certificate { + t.Helper() + cert, err := x509.ParseCertificate(der) + require.NoError(t, err) + return cert +} + +func certPool(t *testing.T, cert tls.Certificate) *x509.CertPool { + t.Helper() + pool := x509.NewCertPool() + pool.AddCert(cert.Leaf) + return pool +} + +// serveTLS accepts connections and completes TLS handshakes until the +// listener closes. Returned address is host:port. +func serveTLS(t *testing.T, cfg *tls.Config) string { + t.Helper() + ln, err := tls.Listen("tcp", "127.0.0.1:0", cfg) + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + go func() { + // Drive the handshake; keep the connection open briefly so + // post-handshake alerts (TLS 1.3 client-cert enforcement) + // reach the client, then close. + if tlsConn, ok := conn.(*tls.Conn); ok { + _ = tlsConn.HandshakeContext(context.Background()) + buf := make([]byte, 1) + _ = tlsConn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, _ = tlsConn.Read(buf) + } + conn.Close() + }() + } + }() + return ln.Addr().String() +} + +func testDiagnose(t *testing.T, address string, tlsCfg *tls.Config) *connectDiagnosis { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // Mirror the SDK's eager GetSystemInfo failure shape. + origErr := fmt.Errorf("failed reaching server: %w", serviceerror.NewDeadlineExceeded("context deadline exceeded")) + return diagnoseConnection(ctx, address, tlsCfg, origErr) +} + +func TestDiagnoseConnection_ClientCertRequired(t *testing.T) { + cert := newTestCert(t) + addr := serveTLS(t, &tls.Config{ + Certificates: []tls.Certificate{cert}, + ClientAuth: tls.RequireAndVerifyClientCert, + ClientCAs: certPool(t, cert), + }) + + // Client trusts the server CA but has no client cert. + d := testDiagnose(t, addr, &tls.Config{RootCAs: certPool(t, cert)}) + // This test also pins the Go crypto/tls alert text ("certificate + // required" / "bad certificate" / "handshake failure") that + // classifyTLSAlert depends on; if it fails after a Go upgrade, revisit + // classifyTLSAlert. + assert.Equal(t, causeClientCertRequired, d.Cause) + requireStage(t, d, diagOK, "TCP connection established") + requireStage(t, d, diagFail, "server requires mTLS") +} + +func TestDiagnoseConnection_ServerPlaintext(t *testing.T) { + // Plain TCP listener that immediately writes a non-TLS response. + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { ln.Close() }) + go func() { + for { + conn, err := ln.Accept() + if err != nil { + return + } + _, _ = conn.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n")) + conn.Close() + } + }() + + d := testDiagnose(t, ln.Addr().String(), &tls.Config{InsecureSkipVerify: true}) + assert.Equal(t, causeServerPlaintext, d.Cause) + requireStage(t, d, diagFail, "did not respond with TLS") +} + +func TestDiagnoseConnection_ServerSpeaksTLS(t *testing.T) { + cert := newTestCert(t) + addr := serveTLS(t, &tls.Config{Certificates: []tls.Certificate{cert}}) + + // No TLS configured on the client. + d := testDiagnose(t, addr, nil) + assert.Equal(t, causeServerSpeaksTLS, d.Cause) + requireStage(t, d, diagFail, "server expects TLS") +} + +func TestDiagnoseConnection_Refused(t *testing.T) { + // Grab a port and close it so nothing is listening. + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + addr := ln.Addr().String() + require.NoError(t, ln.Close()) + + d := testDiagnose(t, addr, nil) + assert.Equal(t, causeTCPRefused, d.Cause) + requireStage(t, d, diagFail, "TCP connection refused") +} + +func TestDiagnoseConnection_DNSFailure(t *testing.T) { + // .invalid is reserved (RFC 2606) and can never resolve. + d := testDiagnose(t, "does-not-exist.invalid:7233", nil) + assert.Equal(t, causeDNS, d.Cause) + requireStage(t, d, diagFail, "DNS lookup") +} + +func TestDiagnoseConnection_UnknownCA(t *testing.T) { + cert := newTestCert(t) + addr := serveTLS(t, &tls.Config{Certificates: []tls.Certificate{cert}}) + + // Client does not trust the server's self-signed cert. + d := testDiagnose(t, addr, &tls.Config{}) + assert.Equal(t, causeCAVerify, d.Cause) + requireStage(t, d, diagFail, "cannot verify server certificate") +} + +func TestDiagnoseConnection_HealthyTLSFallsThroughToGRPC(t *testing.T) { + cert := newTestCert(t) + addr := serveTLS(t, &tls.Config{Certificates: []tls.Certificate{cert}}) + + // TLS is fine; the original (gRPC-level) error should be classified. + d := testDiagnose(t, addr, &tls.Config{RootCAs: certPool(t, cert)}) + assert.Equal(t, causeTimeout, d.Cause) + requireStage(t, d, diagOK, "TLS handshake succeeded") + requireStage(t, d, diagFail, "gRPC connection failed") +} + +func requireStage(t *testing.T, d *connectDiagnosis, status diagStatus, labelSubstr string) { + t.Helper() + for _, stage := range d.Stages { + if stage.Status == status && strings.Contains(stage.Label, labelSubstr) { + return + } + } + t.Fatalf("no stage with status %v containing %q in %+v", status, labelSubstr, d.Stages) +} + +func TestSuggestFix(t *testing.T) { + cloudMeta := connectMeta{ + Args: []string{"workflow", "list", "--address", "foo.bar.tmprl.cloud:7233"}, + Address: "foo.bar.tmprl.cloud:7233", + } + tests := []struct { + name string + diag *connectDiagnosis + meta connectMeta + contains []string + }{ + { + name: "mTLS on cloud endpoint", + diag: &connectDiagnosis{Cause: causeClientCertRequired}, + meta: cloudMeta, + contains: []string{"Temporal Cloud", "--tls-cert-path YourCert.pem", "--tls-key-path YourKey.pem", "temporal config set --prop tls.client_cert_path"}, + }, + { + name: "mTLS on generic endpoint", + diag: &connectDiagnosis{Cause: causeClientCertRequired}, + meta: connectMeta{Args: []string{"workflow", "list"}, Address: "myhost:7233"}, + contains: []string{"requires client certificates", "--tls-cert-path YourCert.pem"}, + }, + { + name: "refused on local default port", + diag: &connectDiagnosis{Cause: causeTCPRefused}, + meta: connectMeta{Address: "127.0.0.1:7233"}, + contains: []string{"temporal server start-dev"}, + }, + { + name: "refused elsewhere", + diag: &connectDiagnosis{Cause: causeTCPRefused}, + meta: connectMeta{Address: "myhost:9999"}, + contains: []string{"Nothing is listening at myhost:9999"}, + }, + { + name: "dns failure from profile address", + diag: &connectDiagnosis{Cause: causeDNS}, + meta: connectMeta{Address: "typo.example.com:7233", AddressSource: "profile", ProfileName: "prod"}, + contains: []string{`Could not resolve "typo.example.com"`, `profile "prod"`, "temporal config get --prop address"}, + }, + { + name: "server speaks TLS", + diag: &connectDiagnosis{Cause: causeServerSpeaksTLS}, + meta: connectMeta{Args: []string{"workflow", "list"}, Address: "myhost:7233"}, + contains: []string{"Add --tls"}, + }, + { + name: "server plaintext", + diag: &connectDiagnosis{Cause: causeServerPlaintext}, + meta: connectMeta{Address: "myhost:7233", TLSConfigured: true}, + contains: []string{"does not appear to use TLS"}, + }, + { + name: "api key rejected", + diag: &connectDiagnosis{Cause: causeAuth}, + meta: connectMeta{Address: "us-west-2.aws.api.temporal.io:7233", HasAPIKey: true}, + contains: []string{"rejected the provided API key"}, + }, + { + name: "cert file unreadable", + diag: &connectDiagnosis{Cause: causeCertFileUnreadable, Detail: "/nope.pem"}, + meta: connectMeta{Address: "myhost:7233"}, + contains: []string{`Cannot read "/nope.pem"`}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := suggestFix(tt.diag, tt.meta) + for _, want := range tt.contains { + assert.Contains(t, got, want) + } + }) + } +} + +func TestReconstructCommand(t *testing.T) { + got := reconstructCommand( + []string{"workflow", "list", "--address", "foo:7233", "--query", "WorkflowType = 'x'"}, + "--tls-cert-path YourCert.pem", + ) + assert.Equal(t, + "temporal workflow list \\\n"+ + " --address foo:7233 \\\n"+ + " --query \"WorkflowType = 'x'\" \\\n"+ + " --tls-cert-path YourCert.pem", + got) +} + +func TestConnectErrorRendering(t *testing.T) { + origErr := fmt.Errorf("failed reaching server: context deadline exceeded") + d := &connectDiagnosis{ + Address: "foo.bar.tmprl.cloud:7233", + Cause: causeClientCertRequired, + Stages: []diagStage{ + {diagOK, "DNS resolved (3 addresses)"}, + {diagOK, "TCP connection established"}, + {diagFail, "TLS handshake failed: server requires mTLS, no valid client certificate was provided"}, + }, + } + err := newConnectError(d, connectMeta{ + Args: []string{"workflow", "list", "--address", "foo.bar.tmprl.cloud:7233"}, + Address: "foo.bar.tmprl.cloud:7233", + }, origErr) + + msg := err.Error() + assert.Contains(t, msg, "failed connecting to Temporal server at foo.bar.tmprl.cloud:7233: TLS handshake failed: server requires client certificate (mTLS)") + assert.Contains(t, msg, "Connecting to foo.bar.tmprl.cloud:7233") + assert.Contains(t, msg, "✓ DNS resolved (3 addresses)") + assert.Contains(t, msg, "✗ TLS handshake failed") + assert.Contains(t, msg, "--tls-cert-path YourCert.pem") + // Unwrap preserves the original error. + assert.ErrorContains(t, err.Unwrap(), "context deadline exceeded") +} diff --git a/internal/temporalcli/connecterror.go b/internal/temporalcli/connecterror.go new file mode 100644 index 000000000..76866cf0d --- /dev/null +++ b/internal/temporalcli/connecterror.go @@ -0,0 +1,216 @@ +package temporalcli + +import ( + "fmt" + "net" + "strings" + + "github.com/fatih/color" +) + +// connectError is returned by dialClient when connecting to the server fails. +// Error() renders the full multi-line diagnosis (summary, probe stages, and a +// suggested fix); Unwrap() preserves the original dial error for +// errors.Is/As. +type connectError struct { + rendered string + cause error +} + +func (e *connectError) Error() string { return e.rendered } +func (e *connectError) Unwrap() error { return e.cause } + +// connectMeta carries the request context the suggestion engine needs to +// propose a concrete fix (e.g. reconstructing the exact command the user +// typed with the missing flags appended). +type connectMeta struct { + // Args are the CLI args as typed (without the binary name). + Args []string + Address string + AddressSource string // "flag", "profile", or "default" + ProfileName string + HasAPIKey bool + TLSConfigured bool +} + +// newConnectError renders a connection failure into a connectError. It must +// be called while command execution is active so that color state (JSON mode, +// --color) is applied correctly; the rendering is captured eagerly. +func newConnectError(d *connectDiagnosis, meta connectMeta, origErr error) *connectError { + var b strings.Builder + b.WriteString("failed connecting to Temporal server at ") + b.WriteString(meta.Address) + b.WriteString(": ") + b.WriteString(connectSummary(d, origErr)) + if len(d.Stages) > 0 { + b.WriteString("\n\n Connecting to ") + b.WriteString(meta.Address) + for _, stage := range d.Stages { + if stage.Status == diagOK { + b.WriteString("\n " + color.GreenString("✓") + " " + stage.Label) + } else { + b.WriteString("\n " + color.RedString("✗") + " " + stage.Label) + } + } + } + if suggestion := suggestFix(d, meta); suggestion != "" { + b.WriteString("\n\n") + b.WriteString(indentLines(suggestion, " ")) + } + return &connectError{rendered: b.String(), cause: origErr} +} + +// connectSummary is the one-line cause appended to the first error line. For +// unclassified failures and timeouts it keeps the original error text so +// scripts matching on strings like "context deadline exceeded" keep working. +func connectSummary(d *connectDiagnosis, origErr error) string { + switch d.Cause { + case causeDNS: + return "could not resolve host" + case causeTCPRefused: + return "connection refused" + case causeTCPTimeout: + return "connection timed out" + case causeServerPlaintext: + return "TLS is enabled but the server did not respond with TLS" + case causeServerSpeaksTLS: + return "the server requires TLS but the CLI is connecting without it" + case causeClientCertRequired: + return "TLS handshake failed: server requires client certificate (mTLS)" + case causeCAVerify: + return "cannot verify server TLS certificate" + case causeHostnameMismatch: + return "server TLS certificate does not match host" + case causeCertFileUnreadable: + return fmt.Sprintf("cannot read file %q", d.Detail) + case causeAuth: + if d.Detail != "" { + return "authentication failed: " + d.Detail + } + return "authentication failed" + default: + return shortErr(origErr) + } +} + +// suggestFix maps a classified failure to one concrete next step. First match +// wins; an empty return means no suggestion (the stages still render). +func suggestFix(d *connectDiagnosis, meta connectMeta) string { + host, port, _ := net.SplitHostPort(meta.Address) + switch d.Cause { + case causeCertFileUnreadable: + return fmt.Sprintf("Cannot read %q — check that the path exists and is readable.", d.Detail) + case causeClientCertRequired: + var b strings.Builder + if isCloudHost(host) { + b.WriteString("This looks like a Temporal Cloud endpoint secured with mTLS. Provide client certificates:") + } else { + b.WriteString("The server requires client certificates (mTLS). Provide them:") + } + b.WriteString("\n\n") + b.WriteString(indentLines(reconstructCommand(meta.Args, "--tls-cert-path YourCert.pem", "--tls-key-path YourKey.pem"), " ")) + b.WriteString("\n\nOr configure once:\n\n") + b.WriteString(" temporal config set --prop tls.client_cert_path YourCert.pem\n") + b.WriteString(" temporal config set --prop tls.client_key_path YourKey.pem") + if strings.HasSuffix(host, ".api.temporal.io") { + b.WriteString("\n\nIf your namespace uses API-key auth instead, pass --api-key YourApiKey.") + } + return b.String() + case causeAuth: + if meta.HasAPIKey { + return "The server rejected the provided API key. Verify the key is valid and that the address is your namespace's gRPC endpoint (for Temporal Cloud API keys, a regional endpoint like us-west-2.aws.api.temporal.io:7233)." + } + return "The server rejected the request as unauthenticated. If it requires an API key, pass --api-key; if it requires mTLS, pass --tls-cert-path and --tls-key-path." + case causeServerPlaintext: + return fmt.Sprintf("The server at %s does not appear to use TLS. Remove --tls and certificate flags, or double-check the address and port.", meta.Address) + case causeServerSpeaksTLS: + var b strings.Builder + b.WriteString("The server requires TLS. Add --tls:") + b.WriteString("\n\n") + b.WriteString(indentLines(reconstructCommand(meta.Args, "--tls"), " ")) + if isCloudHost(host) { + b.WriteString("\n\nTemporal Cloud endpoints also need client credentials: --tls-cert-path/--tls-key-path or --api-key.") + } + return b.String() + case causeDNS: + s := fmt.Sprintf("Could not resolve %q — check the server address.", host) + if meta.AddressSource == "profile" { + profile := meta.ProfileName + if profile == "" { + profile = "default" + } + s += fmt.Sprintf("\nThe address comes from config profile %q — inspect it with:\n\n temporal config get --prop address", profile) + } + return s + case causeTCPRefused: + if isLoopbackHost(host) && port == "7233" { + return fmt.Sprintf("No Temporal server is running at %s. Start a local dev server with:\n\n temporal server start-dev", meta.Address) + } + return fmt.Sprintf("Nothing is listening at %s — verify the address and that the server is running.", meta.Address) + case causeCAVerify: + return "The server's TLS certificate is not trusted by your system roots. If the server uses a private CA, pass --tls-ca-path YourServerCA.pem." + case causeHostnameMismatch: + return fmt.Sprintf("The server's TLS certificate is not valid for %q. If you connect via an IP or alternate name, set --tls-server-name to the name in the certificate.", host) + case causeTCPTimeout, causeTimeout: + return fmt.Sprintf("The connection stalled — a firewall or proxy may be blocking traffic to %s. Verify the address, port, and network path. Bound the wait with --client-connect-timeout.", meta.Address) + } + return "" +} + +func isCloudHost(host string) bool { + return strings.HasSuffix(host, ".tmprl.cloud") || strings.HasSuffix(host, ".api.temporal.io") +} + +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// reconstructCommand re-renders the command the user typed, one option per +// line (matching the style used in help text), with extraFlags appended. +func reconstructCommand(args []string, extraFlags ...string) string { + // Split leading command words from options. + var words, opts []string + for i, arg := range args { + if strings.HasPrefix(arg, "-") { + opts = args[i:] + break + } + words = append(words, arg) + } + var lines []string + lines = append(lines, "temporal "+strings.Join(words, " ")) + for i := 0; i < len(opts); i++ { + line := opts[i] + // Attach the option's value, if the next arg isn't another option. + if !strings.Contains(line, "=") && i+1 < len(opts) && !strings.HasPrefix(opts[i+1], "-") { + i++ + line += " " + quoteArg(opts[i]) + } + lines = append(lines, " "+line) + } + for _, flag := range extraFlags { + lines = append(lines, " "+flag) + } + return strings.Join(lines, " \\\n") +} + +func quoteArg(arg string) string { + if strings.ContainsAny(arg, " \t\"'") { + return fmt.Sprintf("%q", arg) + } + return arg +} + +func indentLines(s, indent string) string { + lines := strings.Split(s, "\n") + for i, line := range lines { + if line != "" { + lines[i] = indent + line + } + } + return strings.Join(lines, "\n") +} From f5ae5ead140e776b0ee88f68d05b281409f7a510 Mon Sep 17 00:00:00 2001 From: Ross Nelson Date: Wed, 8 Jul 2026 12:27:04 -0400 Subject: [PATCH 2/9] Add unit tests for error classification and summary rendering Covers classifyGRPCError, connectSummary's grep-compatibility contract, and an end-to-end case where the failing address comes from a config profile (exercising the new cliext builder metadata). --- internal/temporalcli/client_test.go | 28 +++++++++ internal/temporalcli/connectdiag_test.go | 72 ++++++++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/internal/temporalcli/client_test.go b/internal/temporalcli/client_test.go index 6593e57b5..aa37fa370 100644 --- a/internal/temporalcli/client_test.go +++ b/internal/temporalcli/client_test.go @@ -11,6 +11,7 @@ import ( "encoding/pem" "math/big" "net" + "os" "testing" "time" @@ -223,6 +224,33 @@ func TestConnectDiagnosis_CertFileMissing(t *testing.T) { assert.NotContains(t, msg, "✓", "no probe stages expected when the dial never happened") } +func TestConnectDiagnosis_ProfileAddressNamed(t *testing.T) { + // When the failing address comes from a config profile, the suggestion + // should say so and point at `temporal config get`. + f, err := os.CreateTemp("", "") + require.NoError(t, err) + t.Cleanup(func() { os.Remove(f.Name()) }) + _, err = f.WriteString(` +[profile.default] +address = "does-not-exist.invalid:7233" +`) + require.NoError(t, err) + require.NoError(t, f.Close()) + + h := NewCommandHarness(t) + h.Options.EnvLookup = EnvLookupMap{"TEMPORAL_CONFIG_FILE": f.Name()} + res := h.Execute( + "workflow", "list", + "--client-connect-timeout", "5s", + ) + + require.Error(t, res.Err) + msg := res.Err.Error() + assert.Contains(t, msg, "failed connecting to Temporal server at does-not-exist.invalid:7233") + assert.Contains(t, msg, `The address comes from config profile "default"`) + assert.Contains(t, msg, "temporal config get --prop address") +} + func TestConnectDiagnosis_CommandTimeoutCauseSurvives(t *testing.T) { ln := startBlackHoleListener(t) diff --git a/internal/temporalcli/connectdiag_test.go b/internal/temporalcli/connectdiag_test.go index 5b10e8f1d..12e5d3414 100644 --- a/internal/temporalcli/connectdiag_test.go +++ b/internal/temporalcli/connectdiag_test.go @@ -18,6 +18,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.temporal.io/api/serviceerror" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // newTestCert generates a self-signed server certificate for 127.0.0.1. @@ -195,6 +197,76 @@ func requireStage(t *testing.T, d *connectDiagnosis, status diagStatus, labelSub t.Fatalf("no stage with status %v containing %q in %+v", status, labelSubstr, d.Stages) } +func TestClassifyGRPCError(t *testing.T) { + tests := []struct { + name string + err error + cause connectCause + detail string + }{ + { + name: "serviceerror deadline exceeded", + err: fmt.Errorf("failed reaching server: %w", serviceerror.NewDeadlineExceeded("context deadline exceeded")), + cause: causeTimeout, + }, + { + name: "plain context deadline", + err: fmt.Errorf("failed reaching server: %w", context.DeadlineExceeded), + cause: causeTimeout, + }, + { + name: "unauthenticated status", + err: fmt.Errorf("failed reaching server: %w", status.Error(codes.Unauthenticated, "bad credentials")), + cause: causeAuth, + detail: "bad credentials", + }, + { + name: "permission denied status", + err: fmt.Errorf("failed reaching server: %w", status.Error(codes.PermissionDenied, "nope")), + cause: causeAuth, + }, + { + name: "unclassified error", + err: fmt.Errorf("something else entirely"), + cause: causeUnknown, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cause, detail := classifyGRPCError(tt.err) + assert.Equal(t, tt.cause, cause) + if tt.detail != "" { + assert.Contains(t, detail, tt.detail) + } + }) + } +} + +func TestConnectSummary(t *testing.T) { + origErr := fmt.Errorf("failed reaching server: context deadline exceeded") + tests := []struct { + cause connectCause + want string + }{ + {causeDNS, "could not resolve host"}, + {causeTCPRefused, "connection refused"}, + {causeTCPTimeout, "connection timed out"}, + {causeServerPlaintext, "TLS is enabled but the server did not respond with TLS"}, + {causeServerSpeaksTLS, "the server requires TLS but the CLI is connecting without it"}, + {causeClientCertRequired, "server requires client certificate (mTLS)"}, + {causeCAVerify, "cannot verify server TLS certificate"}, + {causeHostnameMismatch, "server TLS certificate does not match host"}, + // Unclassified failures keep the original error text (minus the SDK + // prefix) so scripts matching on it keep working. + {causeUnknown, "context deadline exceeded"}, + {causeTimeout, "context deadline exceeded"}, + } + for _, tt := range tests { + got := connectSummary(&connectDiagnosis{Cause: tt.cause}, origErr) + assert.Contains(t, got, tt.want, "cause %v", tt.cause) + } +} + func TestSuggestFix(t *testing.T) { cloudMeta := connectMeta{ Args: []string{"workflow", "list", "--address", "foo.bar.tmprl.cloud:7233"}, From f55c4ef8277289cdbac46d41b7f4c441c9778190 Mon Sep 17 00:00:00 2001 From: Ross Nelson Date: Sat, 11 Jul 2026 15:57:08 -0400 Subject: [PATCH 3/9] Fix Windows CI: connection-refused detection and plaintext test race - errors.Is(err, syscall.ECONNREFUSED) doesn't match Windows' WSAECONNREFUSED; fall back to matching the error message. - The plaintext test server closed with the client's ClientHello unread, sending an RST that on Windows discards the buffered HTTP response before the probe reads it; drain before closing. --- internal/temporalcli/connectdiag.go | 11 ++++++++++- internal/temporalcli/connectdiag_test.go | 16 ++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/internal/temporalcli/connectdiag.go b/internal/temporalcli/connectdiag.go index e6eda32a7..3a5593416 100644 --- a/internal/temporalcli/connectdiag.go +++ b/internal/temporalcli/connectdiag.go @@ -104,7 +104,7 @@ func diagnoseConnection(ctx context.Context, address string, tlsCfg *tls.Config, // Stage: TCP conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", address) if err != nil { - if errors.Is(err, syscall.ECONNREFUSED) { + if isConnRefused(err) { d.fail("TCP connection refused: nothing is listening at " + address) d.Cause = causeTCPRefused } else if isTimeout(err) { @@ -246,6 +246,15 @@ func (d *connectDiagnosis) fail(label string) { d.Stages = append(d.Stages, diagStage{diagFail, label}) } +// isConnRefused reports whether err is a refused TCP connection. errors.Is +// against syscall.ECONNREFUSED matches on unix, but Windows dials fail with +// WSAECONNREFUSED ("No connection could be made because the target machine +// actively refused it"), which Go does not map to syscall.ECONNREFUSED. +func isConnRefused(err error) bool { + return errors.Is(err, syscall.ECONNREFUSED) || + strings.Contains(err.Error(), "refused") +} + func isTimeout(err error) bool { var netErr net.Error return errors.Is(err, context.DeadlineExceeded) || diff --git a/internal/temporalcli/connectdiag_test.go b/internal/temporalcli/connectdiag_test.go index 12e5d3414..456528747 100644 --- a/internal/temporalcli/connectdiag_test.go +++ b/internal/temporalcli/connectdiag_test.go @@ -127,8 +127,20 @@ func TestDiagnoseConnection_ServerPlaintext(t *testing.T) { if err != nil { return } - _, _ = conn.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n")) - conn.Close() + go func() { + defer conn.Close() + _, _ = conn.Write([]byte("HTTP/1.1 400 Bad Request\r\n\r\n")) + // Drain the client's ClientHello before closing: closing with + // unread data pending sends an RST, which on Windows discards + // the response before the client can read it. + _ = conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + buf := make([]byte, 1024) + for { + if _, err := conn.Read(buf); err != nil { + return + } + } + }() } }() From e23832e7a92d0b7ed8daf104b8e75db6ee454b35 Mon Sep 17 00:00:00 2001 From: Ross Nelson Date: Wed, 22 Jul 2026 15:13:26 -0400 Subject: [PATCH 4/9] Refactor connection errors through terminal reports --- cliext/client.go | 30 -- cmd/temporal/main.go | 4 +- internal/temporalcli/client.go | 108 +++++-- internal/temporalcli/client_test.go | 22 +- internal/temporalcli/commands.activity.go | 21 +- .../commands.activity_internal_test.go | 31 ++ .../temporalcli/commands.activity_test.go | 7 +- internal/temporalcli/commands.extension.go | 12 +- .../temporalcli/commands.extension_test.go | 9 +- internal/temporalcli/commands.go | 233 +++++++++++++-- .../temporalcli/commands.schedule_test.go | 3 +- internal/temporalcli/commands_test.go | 52 +++- internal/temporalcli/connectdiag.go | 20 +- internal/temporalcli/connectdiag_test.go | 88 ++++-- internal/temporalcli/connecterror.go | 190 ++++++------ internal/temporalcli/terminal.go | 276 ++++++++++++++++++ internal/temporalcli/terminal_test.go | 268 +++++++++++++++++ 17 files changed, 1121 insertions(+), 253 deletions(-) create mode 100644 internal/temporalcli/commands.activity_internal_test.go create mode 100644 internal/temporalcli/terminal.go create mode 100644 internal/temporalcli/terminal_test.go diff --git a/cliext/client.go b/cliext/client.go index e723c1d73..48eaa7508 100644 --- a/cliext/client.go +++ b/cliext/client.go @@ -2,9 +2,7 @@ package cliext import ( "context" - "errors" "fmt" - "io/fs" "log/slog" "net/http" "strings" @@ -36,19 +34,6 @@ type ClientOptionsBuilder struct { // configured. Callers can use it to decode payloads outside the gRPC // interceptor chain (e.g. payloads nested inside opaque proto bytes). PayloadCodec converter.PayloadCodec - - // ResolvedAddress is populated by Build with the final server address, - // after profile resolution and namespace templating. - ResolvedAddress string - // ResolvedAddressSource is populated by Build with where the final address - // came from: "flag", "profile", or "default". - ResolvedAddressSource string - // ResolvedProfileName is populated by Build with the config profile name - // used, if any. - ResolvedProfileName string - // HasAPIKey is populated by Build and reports whether API-key credentials - // are configured (via flag, profile, or OAuth). - HasAPIKey bool } type oauthCredentials struct { @@ -123,12 +108,8 @@ func (b *ClientOptionsBuilder) Build(ctx context.Context) (client.Options, error // override the profile version unless it was _explicitly_ set. if cfg.FlagSet != nil && cfg.FlagSet.Changed("address") { profile.Address = cfg.Address - b.ResolvedAddressSource = "flag" } else if profile.Address == "" { profile.Address = cfg.Address - b.ResolvedAddressSource = "default" - } else { - b.ResolvedAddressSource = "profile" } var namespaceExplicitlySet bool if cfg.FlagSet != nil && cfg.FlagSet.Changed("namespace") { @@ -145,7 +126,6 @@ func (b *ClientOptionsBuilder) Build(ctx context.Context) (client.Options, error if addressHasNamespaceTemplate { profile.Address = strings.ReplaceAll(profile.Address, addressNamespaceTemplate, profile.Namespace) } - b.ResolvedAddress = profile.Address // Set API key on profile if provided if cfg.ApiKey != "" { @@ -224,18 +204,9 @@ func (b *ClientOptionsBuilder) Build(ctx context.Context) (client.Options, error profile.Codec.Auth = cfg.CodecAuth } - b.ResolvedProfileName = common.Profile - b.HasAPIKey = profile.APIKey != "" - // Convert profile to client options. clientOpts, err := profile.ToClientOptions(envconfig.ToClientOptionsRequest{}) if err != nil { - // File-path errors (e.g. an unreadable TLS cert) are common enough to - // deserve naming the offending file. - var pathErr *fs.PathError - if errors.As(err, &pathErr) { - return client.Options{}, fmt.Errorf("failed to build client options: cannot read file %q: %w", pathErr.Path, err) - } return client.Options{}, fmt.Errorf("failed to build client options: %w", err) } @@ -278,7 +249,6 @@ func (b *ClientOptionsBuilder) Build(ctx context.Context) (client.Options, error profileName: result.ProfileName, } clientOpts.Credentials = client.NewAPIKeyDynamicCredentials(creds.getToken) - b.HasAPIKey = true } } diff --git a/cmd/temporal/main.go b/cmd/temporal/main.go index 0c482a6ed..8c14f2f83 100644 --- a/cmd/temporal/main.go +++ b/cmd/temporal/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "os" "github.com/temporalio/cli/internal/temporalcli" @@ -15,5 +16,6 @@ import ( func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - temporalcli.Execute(ctx, temporalcli.CommandOptions{}) + result := temporalcli.Execute(ctx, temporalcli.CommandOptions{}) + os.Exit(result.ExitStatus) } diff --git a/internal/temporalcli/client.go b/internal/temporalcli/client.go index 81dfe97c7..d96a41685 100644 --- a/internal/temporalcli/client.go +++ b/internal/temporalcli/client.go @@ -7,10 +7,12 @@ import ( "io/fs" "os" "os/user" + "strings" "github.com/temporalio/cli/cliext" "go.temporal.io/api/common/v1" "go.temporal.io/sdk/client" + "go.temporal.io/sdk/contrib/envconfig" "go.temporal.io/sdk/converter" "go.temporal.io/sdk/workflow" "google.golang.org/grpc" @@ -48,6 +50,7 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client. } c.Identity = "temporal-cli:" + username + "@" + hostname } + captureEffectiveConnectionSecrets(cctx, c) // Build client options using cliext builder := &cliext.ClientOptionsBuilder{ @@ -63,11 +66,11 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client. var pathErr *fs.PathError if errors.As(err, &pathErr) { diag := &connectDiagnosis{ - Address: builder.ResolvedAddress, + Address: c.Address, Cause: causeCertFileUnreadable, Detail: pathErr.Path, } - return nil, nil, newConnectError(diag, connectMetaFromBuilder(cctx, builder), err) + return nil, nil, newConnectError(diag, connectMetaFromOptions(cctx, c, client.Options{HostPort: c.Address}), err) } return nil, nil, err } @@ -100,7 +103,7 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client. cl, err := client.DialContext(dialCtx, clientOpts) if err != nil { - return nil, nil, dialConnectError(cctx, dialCtx, builder, clientOpts, err) + return nil, nil, dialConnectError(cctx, dialCtx, c, clientOpts, err) } // Since this namespace value is used by many commands after this call, @@ -115,7 +118,7 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client. func dialConnectError( cctx *CommandContext, dialCtx context.Context, - builder *cliext.ClientOptionsBuilder, + configured *cliext.ClientOptions, clientOpts client.Options, origErr error, ) error { @@ -134,27 +137,90 @@ func dialConnectError( if v, _ := cctx.Options.EnvLookup.LookupEnv("TEMPORAL_CLI_DISABLE_CONNECT_DIAGNOSIS"); v != "" { return fmt.Errorf("failed connecting to Temporal server at %v: %w", clientOpts.HostPort, origErr) } - // The probe has its own internal budget, but never let it exceed the - // user's explicit connect timeout. - probeCtx := context.Context(cctx) - if timeout := cctx.RootCommand.CommonOptions.ClientConnectTimeout.Duration(); timeout > 0 { - var cancel context.CancelFunc - probeCtx, cancel = context.WithTimeout(cctx, timeout) - defer cancel() - } - diag := diagnoseConnection(probeCtx, clientOpts.HostPort, clientOpts.ConnectionOptions.TLS, origErr) - meta := connectMetaFromBuilder(cctx, builder) + // Diagnosis uses the remaining command context and applies its own 3s cap. + // ClientConnectTimeout governs only the original dial. + diag := diagnoseConnection(cctx, clientOpts.HostPort, clientOpts.ConnectionOptions.TLS, origErr) + meta := connectMetaFromOptions(cctx, configured, clientOpts) meta.TLSConfigured = clientOpts.ConnectionOptions.TLS != nil return newConnectError(diag, meta, origErr) } -func connectMetaFromBuilder(cctx *CommandContext, builder *cliext.ClientOptionsBuilder) connectMeta { - return connectMeta{ - Args: cctx.Options.Args, - Address: builder.ResolvedAddress, - AddressSource: builder.ResolvedAddressSource, - ProfileName: builder.ResolvedProfileName, - HasAPIKey: builder.HasAPIKey, +func connectMetaFromOptions(cctx *CommandContext, configured *cliext.ClientOptions, resolved client.Options) connectMeta { + meta := connectMeta{Address: resolved.HostPort, HasAPIKey: cctx.hasAPIKey, HasOAuth: cctx.hasOAuth} + if configured != nil && configured.FlagSet != nil && configured.FlagSet.Changed("address") { + meta.AddressSource = "flag" + } else if configured != nil && resolved.HostPort != configured.Address { + meta.AddressSource = "profile" + } else { + meta.AddressSource = "default" + } + if cctx.RootCommand != nil { + meta.ProfileName = cctx.RootCommand.CommonOptions.Profile + } + return meta +} + +func captureEffectiveConnectionSecrets(cctx *CommandContext, configured *cliext.ClientOptions) { + if configured != nil { + cctx.registerSecrets( + configured.ApiKey, + configured.CodecAuth, + configured.TlsCertData, + configured.TlsKeyData, + configured.TlsCaData, + ) + for _, value := range append(append([]string(nil), configured.CodecHeader...), configured.GrpcMeta...) { + cctx.registerSecrets(value) + if _, secret, ok := strings.Cut(value, "="); ok { + cctx.registerSecrets(secret) + } + } + if configured.ApiKey != "" { + cctx.hasAPIKey = true + } + } + if cctx.RootCommand == nil { + return + } + common := cctx.RootCommand.CommonOptions + if !common.DisableConfigFile || !common.DisableConfigEnv { + profile, err := envconfig.LoadClientConfigProfile(envconfig.LoadClientConfigProfileOptions{ + ConfigFilePath: common.ConfigFile, ConfigFileProfile: common.Profile, + DisableFile: common.DisableConfigFile, DisableEnv: common.DisableConfigEnv, + EnvLookup: cctx.Options.EnvLookup, + }) + if err == nil { + cctx.registerSecrets(profile.APIKey) + cctx.hasAPIKey = cctx.hasAPIKey || profile.APIKey != "" + if profile.TLS != nil { + cctx.registerSecrets(string(profile.TLS.ClientCertData), string(profile.TLS.ClientKeyData), string(profile.TLS.ServerCACertData)) + } + if profile.Codec != nil { + cctx.registerSecrets(profile.Codec.Auth) + } + for _, value := range profile.GRPCMeta { + cctx.registerSecrets(value) + } + } + } + // Match ClientOptionsBuilder precedence: an explicit --api-key suppresses + // OAuth loading, while a profile API key can still be replaced by a usable + // OAuth token from the same profile. + if common.DisableConfigFile || configured != nil && configured.ApiKey != "" { + return + } + oauth, err := cliext.LoadClientOAuth(cliext.LoadClientOAuthOptions{ + ConfigFilePath: common.ConfigFile, ProfileName: common.Profile, EnvLookup: cctx.Options.EnvLookup, + }) + if err != nil || oauth.OAuth == nil { + return + } + if oauth.OAuth.ClientConfig != nil { + cctx.registerSecrets(oauth.OAuth.ClientConfig.ClientSecret) + } + if oauth.OAuth.Token != nil { + cctx.registerSecrets(oauth.OAuth.Token.AccessToken, oauth.OAuth.Token.RefreshToken) + cctx.hasOAuth = oauth.OAuth.Token.AccessToken != "" } } diff --git a/internal/temporalcli/client_test.go b/internal/temporalcli/client_test.go index aa37fa370..c83039313 100644 --- a/internal/temporalcli/client_test.go +++ b/internal/temporalcli/client_test.go @@ -108,7 +108,7 @@ func TestClientConnectTimeout(t *testing.T) { assert.Contains(t, res.Err.Error(), "deadline exceeded") // The friendly cause attached to the connect timeout must survive. assert.Contains(t, res.Err.Error(), "command timed out after 50ms") - assert.Less(t, elapsed, 2*time.Second, "dial and diagnosis should respect the connect timeout") + assert.Less(t, elapsed, 4*time.Second, "diagnosis should respect its independent three-second cap") } func TestConnectDiagnosis_MTLSRequired(t *testing.T) { @@ -124,12 +124,12 @@ func TestConnectDiagnosis_MTLSRequired(t *testing.T) { ) require.Error(t, res.Err) - msg := res.Err.Error() + msg := res.Stderr.String() assert.Contains(t, msg, "Connecting to "+addr) assert.Contains(t, msg, "✓ TCP connection established") assert.Contains(t, msg, "✗ TLS handshake failed: server requires mTLS") - assert.Contains(t, msg, "--tls-cert-path YourCert.pem") - assert.Contains(t, msg, "--tls-key-path YourKey.pem") + assert.Contains(t, msg, "tls.client_cert_path --value YourCert.pem") + assert.Contains(t, msg, "tls.client_key_path --value YourKey.pem") } func TestConnectDiagnosis_Refused(t *testing.T) { @@ -147,7 +147,7 @@ func TestConnectDiagnosis_Refused(t *testing.T) { ) require.Error(t, res.Err) - msg := res.Err.Error() + msg := res.Stderr.String() assert.Contains(t, msg, "connection refused") assert.Contains(t, msg, "✗ TCP connection refused") assert.Contains(t, msg, "Nothing is listening at "+addr) @@ -163,7 +163,7 @@ func TestConnectDiagnosis_DNSFailure(t *testing.T) { ) require.Error(t, res.Err) - msg := res.Err.Error() + msg := res.Stderr.String() assert.Contains(t, msg, "could not resolve host") assert.Contains(t, msg, "✗ DNS lookup") assert.Contains(t, msg, `Could not resolve "does-not-exist.invalid"`) @@ -184,7 +184,7 @@ func TestConnectDiagnosis_JSONOutputHasNoANSI(t *testing.T) { ) require.Error(t, res.Err) - assert.NotContains(t, res.Err.Error(), "\x1b[", "diagnosis must not contain ANSI escapes in JSON mode") + assert.NotContains(t, res.Stderr.String(), "\x1b[", "diagnosis must not contain ANSI escapes in JSON mode") assert.Empty(t, res.Stdout.String(), "connection failures must not write to stdout") } @@ -203,7 +203,7 @@ func TestConnectDiagnosis_Disabled(t *testing.T) { ) require.Error(t, res.Err) - msg := res.Err.Error() + msg := res.Stderr.String() assert.Contains(t, msg, "failed connecting to Temporal server at "+addr) assert.NotContains(t, msg, "✗", "diagnosis must be suppressed when disabled") } @@ -218,7 +218,7 @@ func TestConnectDiagnosis_CertFileMissing(t *testing.T) { ) require.Error(t, res.Err) - msg := res.Err.Error() + msg := res.Stderr.String() assert.Contains(t, msg, "cannot read file") assert.Contains(t, msg, "/definitely/does/not/exist") assert.NotContains(t, msg, "✓", "no probe stages expected when the dial never happened") @@ -245,10 +245,10 @@ address = "does-not-exist.invalid:7233" ) require.Error(t, res.Err) - msg := res.Err.Error() + msg := res.Stderr.String() assert.Contains(t, msg, "failed connecting to Temporal server at does-not-exist.invalid:7233") assert.Contains(t, msg, `The address comes from config profile "default"`) - assert.Contains(t, msg, "temporal config get --prop address") + assert.Contains(t, msg, "temporal config get --prop address --profile default") } func TestConnectDiagnosis_CommandTimeoutCauseSurvives(t *testing.T) { diff --git a/internal/temporalcli/commands.activity.go b/internal/temporalcli/commands.activity.go index 582f5d622..ba7ecc90d 100644 --- a/internal/temporalcli/commands.activity.go +++ b/internal/temporalcli/commands.activity.go @@ -223,7 +223,7 @@ func getActivityResult(cctx *CommandContext, cl client.Client, namespace, activi if err != nil { var notFound *serviceerror.NotFound if errors.As(err, ¬Found) { - return fmt.Errorf("activity not found: %s", activityID) + return &activityNotFoundError{activityID: activityID, cause: err} } return fmt.Errorf("failed polling activity result: %w", err) } @@ -241,6 +241,25 @@ func getActivityResult(cctx *CommandContext, cl client.Client, namespace, activi } } +// activityNotFoundError retains the server's typed NotFound error while +// carrying the audited display semantics for this command family. +type activityNotFoundError struct { + activityID string + cause error +} + +func (e *activityNotFoundError) Error() string { + return fmt.Sprintf("activity not found: %s", e.activityID) +} + +func (e *activityNotFoundError) Unwrap() error { + return e.cause +} + +func (e *activityNotFoundError) report() errorReport { + return errorReport{Summary: "standalone Activity not found"} +} + // Matches the SDK's pollActivityTimeout in internal_activity_client.go. const pollActivityTimeout = 60 * time.Second diff --git a/internal/temporalcli/commands.activity_internal_test.go b/internal/temporalcli/commands.activity_internal_test.go new file mode 100644 index 000000000..43829453d --- /dev/null +++ b/internal/temporalcli/commands.activity_internal_test.go @@ -0,0 +1,31 @@ +package temporalcli + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.temporal.io/api/serviceerror" +) + +func TestActivityNotFoundErrorPreservesCauseAndHasConservativeReport(t *testing.T) { + cause, ok := serviceerror.NewNotFound("server detail").(*serviceerror.NotFound) + require.True(t, ok) + wrappedCause := fmt.Errorf("poll failed: %w", cause) + err := &activityNotFoundError{activityID: "activity-id", cause: wrappedCause} + + var notFound *serviceerror.NotFound + assert.ErrorAs(t, err, ¬Found) + assert.Same(t, cause, notFound) + assert.Equal(t, "activity not found: activity-id", err.Error()) + + report := err.report() + assert.Equal(t, "standalone Activity not found", report.Summary) + assert.Empty(t, report.Context) + assert.Empty(t, report.Checks) + assert.Nil(t, report.Action) + assert.True(t, errors.Is(err, wrappedCause)) + assert.True(t, errors.Is(err, cause)) +} diff --git a/internal/temporalcli/commands.activity_test.go b/internal/temporalcli/commands.activity_test.go index ec6b50a4f..1bdfd4e55 100644 --- a/internal/temporalcli/commands.activity_test.go +++ b/internal/temporalcli/commands.activity_test.go @@ -819,7 +819,12 @@ func (s *SharedServerSuite) TestActivity_Result_NotFound() { ) s.Error(res.Err) s.Contains(res.Err.Error(), "not found") - s.NotContains(res.Stdout.String(), "FAILED") + var notFound *serviceerror.NotFound + s.ErrorAs(res.Err, ¬Found) + s.Equal(1, res.Runtime.ExitStatus) + s.Contains(res.Stderr.String(), "Error: standalone Activity not found") + s.NotContains(res.Stderr.String(), "Try") + s.Empty(res.Stdout.String()) } func (s *SharedServerSuite) TestActivity_Describe() { diff --git a/internal/temporalcli/commands.extension.go b/internal/temporalcli/commands.extension.go index 794242263..2ac7a1df6 100644 --- a/internal/temporalcli/commands.extension.go +++ b/internal/temporalcli/commands.extension.go @@ -25,6 +25,14 @@ var cliArgsToParseForExtension = map[string]bool{ "command-timeout": true, } +// ExtensionNonZeroExit preserves a started extension's exit status and marks +// its output as extension-owned, so the parent does not render another error. +type ExtensionNonZeroExit struct { + *exec.ExitError +} + +func (err ExtensionNonZeroExit) Unwrap() error { return err.ExitError } + // tryExecuteExtension tries to execute an extension command if the command is not a built-in command. // It returns an error if the extension command fails, and a boolean indicating whether an extension was executed. func tryExecuteExtension(cctx *CommandContext, tcmd *TemporalCommand) (error, bool) { @@ -77,8 +85,8 @@ func tryExecuteExtension(cctx *CommandContext, tcmd *TemporalCommand) (error, bo if ctx.Err() != nil { return fmt.Errorf("program interrupted"), true } - if _, ok := err.(*exec.ExitError); ok { - return nil, true + if exitError, ok := err.(*exec.ExitError); ok { + return ExtensionNonZeroExit{exitError}, true } return fmt.Errorf("extension %s failed: %w", extPath, err), true } diff --git a/internal/temporalcli/commands.extension_test.go b/internal/temporalcli/commands.extension_test.go index 266a8fcd1..2d438f146 100644 --- a/internal/temporalcli/commands.extension_test.go +++ b/internal/temporalcli/commands.extension_test.go @@ -12,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/temporalio/cli/internal/temporalcli" "golang.org/x/tools/imports" ) @@ -237,7 +238,8 @@ func TestExtension_FailsOnNonExecutableCommand(t *testing.T) { res := h.Execute("foo") - assert.Contains(t, res.Stdout.String(), "Usage:") // help text is shown + assert.Empty(t, res.Stdout.String()) + assert.Contains(t, res.Stderr.String(), "Usage:") assert.EqualError(t, res.Err, "unknown command") } @@ -248,7 +250,10 @@ func TestExtension_PassesThroughNonZeroExit(t *testing.T) { res := h.Execute("foo") assert.Equal(t, "Args: temporal-foo \n", res.Stdout.String()) - assert.NoError(t, res.Err) + var extensionErr temporalcli.ExtensionNonZeroExit + assert.ErrorAs(t, res.Err, &extensionErr) + assert.Equal(t, 42, res.Runtime.ExitStatus) + assert.Empty(t, res.Stderr.String(), "extension owns its stderr") } func TestExtension_FailsOnCommandTimeout(t *testing.T) { diff --git a/internal/temporalcli/commands.go b/internal/temporalcli/commands.go index df5fcdb19..7ec38e187 100644 --- a/internal/temporalcli/commands.go +++ b/internal/temporalcli/commands.go @@ -58,10 +58,15 @@ type CommandContext struct { // Is set to true if any command actually started running. This is a hack to workaround the fact // that cobra does not properly exit nonzero if an unknown command/subcommand is given. ActuallyRanCommand bool + preRunSucceeded bool // Root/current command only set inside of pre-run RootCommand *TemporalCommand CurrentCommand *cobra.Command + commandCancel context.CancelFunc + secretValues []string + hasAPIKey bool + hasOAuth bool } type IOStreams struct { @@ -81,7 +86,8 @@ type CommandOptions struct { // If nil, [envconfig.EnvLookupOS] is used. EnvLookup envconfig.EnvLookup - // Defaults to logging error then os.Exit(1) + // Fail is used by generated Run callbacks to hand their final error to the + // command runtime. Execute replaces it with a command-scoped recorder. Fail func(error) AdditionalClientGRPCDialOptions []grpc.DialOption @@ -137,24 +143,6 @@ func (c *CommandContext) preprocessOptions() error { c.Options.Stderr = os.Stderr } - // Setup default fail callback - if c.Options.Fail == nil { - c.Options.Fail = func(err error) { - // If context is closed, say that the program was interrupted and - // ignore the actual error — unless the context carries a - // descriptive cause such as a --command-timeout expiry. - if c.Err() != nil { - if cause := context.Cause(c); cause != nil && !errors.Is(cause, context.Canceled) { - err = cause - } else { - err = fmt.Errorf("program interrupted") - } - } - fmt.Fprintf(c.Options.Stderr, "Error: %v\n", err) - os.Exit(1) - } - } - // Update options according to the env file. MUST BE DONE LAST. // // Why last? Callers need the CommandContext to be usable no matter what, @@ -360,10 +348,16 @@ func (c *CommandContext) promptString(message string, expected string, autoConfi return line == expected, nil } -// Execute runs the Temporal CLI with the given context and options. This -// intentionally does not return an error but rather invokes Fail on the -// options. -func Execute(ctx context.Context, options CommandOptions) { +// Execute runs the Temporal CLI and returns its terminal outcome. It never +// exits for a terminal command failure; cmd/temporal/main.go owns that process +// exit boundary. Existing success-output broken-pipe exits in printer are a +// separate compatibility path outside terminal error handling. +func Execute(ctx context.Context, options CommandOptions) Result { + var recorder commandErrorRecorder + options.Fail = recorder.Record + origNoColor := color.NoColor + defer func() { color.NoColor = origNoColor }() + // Create context and run. We always get a context and cancel func back even // if an error was returned. This is so we can use the context to print an // error message using the appropriate Fail() method, regardless of why the @@ -373,9 +367,17 @@ func Execute(ctx context.Context, options CommandOptions) { // config file, or some other issue in their environment.) cctx, cancel, err := NewCommandContext(ctx, options) defer cancel() + defer func() { + if cctx.commandCancel != nil { + cctx.commandCancel() + } + }() if err == nil { cmd := NewTemporalCommand(cctx) + // The terminal handler owns both error and input-error usage rendering. + cmd.Command.SilenceErrors = true + cmd.Command.SilenceUsage = true cmd.Command.SetArgs(cctx.Options.Args) cmd.Command.SetOut(cctx.Options.Stdout) cmd.Command.SetErr(cctx.Options.Stderr) @@ -383,20 +385,36 @@ func Execute(ctx context.Context, options CommandOptions) { // Try extension first. err, cctx.ActuallyRanCommand = tryExecuteExtension(cctx, cmd) if err != nil { - cctx.Options.Fail(err) - return + return finishCommand(cctx, err, usageForArgs(&cmd.Command, cctx.Options.Args)) } // Run builtin command if no extension handled the command. if !cctx.ActuallyRanCommand { - err = cmd.Command.ExecuteContext(cctx) + if unknownCommandPath(&cmd.Command, cctx.Options.Args) { + return finishCommand(cctx, fmt.Errorf("unknown command"), usageForArgs(&cmd.Command, nil)) + } + var cobraErr error + recorded := runRecordedCommand(func() { cobraErr = cmd.Command.ExecuteContext(cctx) }) + if recorded { + return finishCommand(cctx, recorder.Err(), "") + } + if cobraErr != nil { + usage := "" + if !cctx.ActuallyRanCommand || cctx.preRunSucceeded { + usage = usageForArgs(&cmd.Command, cctx.Options.Args) + } + return finishCommand(cctx, cobraErr, usage) + } + if err = recorder.Err(); err != nil { + return finishCommand(cctx, err, "") + } } } if err != nil { // Either we failed to create the context, OR the command itself failed. // Either way, we need to print an error message. - cctx.Options.Fail(err) + return finishCommand(cctx, err, "") } // If no command ever actually got run, exit nonzero with an error. This is @@ -413,9 +431,165 @@ func Execute(ctx context.Context, options CommandOptions) { if slices.ContainsFunc(cctx.Options.Args, func(a string) bool { return slices.Contains(zeroExitArgs, a) }) { + return Result{} + } + return finishCommand(cctx, fmt.Errorf("unknown command"), "") + } + return Result{} +} + +func usageForArgs(root *cobra.Command, args []string) string { + command, _, findErr := root.Find(args) + if findErr != nil || command == nil { + command = root + } + return command.UsageString() +} + +func unknownCommandPath(root *cobra.Command, args []string) bool { + current := root + for i := 0; i < len(args); i++ { + arg := args[i] + if strings.HasPrefix(arg, "-") { + name, inline := parseFlagArg(arg) + flag, takesValue := lookupFlag(current, name) + if flag == nil { + // Cobra owns invalid-flag classification. Guessing whether an + // unknown flag consumes the next token can misreport that token + // as an unknown command. + return false + } + if takesValue && !inline { + i++ + } + continue + } + var next *cobra.Command + for _, child := range current.Commands() { + if child.Name() == arg || slices.Contains(child.Aliases, arg) { + next = child + break + } + } + if next == nil { + return current.HasAvailableSubCommands() && current.Run == nil && current.RunE == nil + } + current = next + } + return false +} + +func finishCommand(cctx *CommandContext, err error, usage string) Result { + var extensionErr ExtensionNonZeroExit + if errors.As(err, &extensionErr) { + return Result{CommandErr: err, ExitStatus: extensionErr.ExitCode()} + } + displayErr := err + if cctx.Err() != nil { + if cause := context.Cause(cctx); cause != nil && !errors.Is(cause, context.Canceled) { + displayErr = cause + } else { + displayErr = fmt.Errorf("program interrupted") + } + } + return handleTerminalError(err, terminalOptions{ + Stderr: cctx.Options.Stderr, + Color: cctx.terminalColorEnabled(), + KnownSecrets: cctx.knownSecrets(), + Usage: usage, + DisplayErr: displayErr, + }) +} + +func (c *CommandContext) terminalColorEnabled() bool { + jsonOutput := c.JSONOutput + colorEnabled := !color.NoColor + for i := 0; i < len(c.Options.Args); i++ { + name, value, inline := strings.Cut(c.Options.Args[i], "=") + if !inline && i+1 < len(c.Options.Args) && (name == "-o" || name == "--output" || name == "--color") { + i++ + value = c.Options.Args[i] + } + switch name { + case "-o", "--output": + jsonOutput = jsonOutput || value == "json" || value == "jsonl" + case "--color": + if value == "always" { + colorEnabled = true + } else if value == "never" { + colorEnabled = false + } + } + } + if c.RootCommand != nil { + jsonOutput = jsonOutput || c.RootCommand.Output.Value == "json" || c.RootCommand.Output.Value == "jsonl" + switch c.RootCommand.Color.Value { + case "always": + colorEnabled = true + case "never": + colorEnabled = false + } + } + return colorEnabled && !jsonOutput +} + +func (c *CommandContext) knownSecrets() []string { + secretFlags := map[string]bool{ + "api-key": true, "codec-auth": true, "codec-header": true, + "grpc-meta": true, "tls-cert-data": true, "tls-key-data": true, + "tls-ca-data": true, + } + secrets := append([]string(nil), c.secretValues...) + if c.configEnvironmentEnabled() { + for _, name := range []string{ + "TEMPORAL_API_KEY", "TEMPORAL_CODEC_AUTH", "TEMPORAL_TLS_CERT_DATA", + "TEMPORAL_TLS_KEY_DATA", "TEMPORAL_TLS_CA_DATA", + } { + if value, ok := c.Options.EnvLookup.LookupEnv(name); ok && value != "" { + secrets = append(secrets, value) + } + } + } + if c.CurrentCommand == nil { + return secrets + } + appendFlagValues := func(flag *pflag.Flag) { + if values, ok := flag.Value.(pflag.SliceValue); ok { + for _, value := range values.GetSlice() { + secrets = append(secrets, value) + if _, secret, found := strings.Cut(value, "="); found && secret != "" { + secrets = append(secrets, secret) + } + } return } - cctx.Options.Fail(fmt.Errorf("unknown command")) + secrets = append(secrets, flag.Value.String()) + } + c.CurrentCommand.Flags().VisitAll(func(flag *pflag.Flag) { + if secretFlags[flag.Name] && flag.Changed { + appendFlagValues(flag) + } + }) + c.CurrentCommand.InheritedFlags().VisitAll(func(flag *pflag.Flag) { + if secretFlags[flag.Name] && flag.Changed { + appendFlagValues(flag) + } + }) + return secrets +} + +func (c *CommandContext) configEnvironmentEnabled() bool { + if c.RootCommand != nil { + return !c.RootCommand.CommonOptions.DisableConfigEnv + } + return !slices.Contains(c.Options.Args, "--disable-config-env") +} + +func (c *CommandContext) registerSecrets(values ...string) { + for _, value := range values { + if value != "" { + c.secretValues = append(c.secretValues, value) + } } } @@ -517,6 +691,7 @@ func (c *TemporalCommand) initCommand(cctx *CommandContext) { } } } + cctx.preRunSucceeded = res == nil return res } c.Command.PersistentPostRun = func(*cobra.Command, []string) { @@ -581,7 +756,7 @@ func (c *TemporalCommand) preRun(cctx *CommandContext) error { } cctx.JSONShorthandPayloads = !c.NoJsonShorthandPayloads if c.CommandTimeout.Duration() > 0 { - cctx.Context, _ = context.WithTimeoutCause( + cctx.Context, cctx.commandCancel = context.WithTimeoutCause( cctx.Context, c.CommandTimeout.Duration(), fmt.Errorf("command timed out after %v", c.CommandTimeout.Duration()), diff --git a/internal/temporalcli/commands.schedule_test.go b/internal/temporalcli/commands.schedule_test.go index d3bf978d9..d49a33f52 100644 --- a/internal/temporalcli/commands.schedule_test.go +++ b/internal/temporalcli/commands.schedule_test.go @@ -38,9 +38,8 @@ func (s *SharedServerSuite) createSchedule(args ...string) (schedId, schedWfId s "--address", s.Address(), "-s", schedId, }, - Fail: func(error) {}, } - temporalcli.Execute(ctx, options) + _ = temporalcli.Execute(ctx, options) }) res = s.Execute(append([]string{ "schedule", "create", diff --git a/internal/temporalcli/commands_test.go b/internal/temporalcli/commands_test.go index 1b1a18e51..0a171d0e0 100644 --- a/internal/temporalcli/commands_test.go +++ b/internal/temporalcli/commands_test.go @@ -6,6 +6,8 @@ import ( "fmt" "io" "log/slog" + "os" + "path/filepath" "regexp" "slices" "strings" @@ -120,6 +122,38 @@ func TestAssertContainsOnSameLine(t *testing.T) { require.NoError(t, AssertContainsOnSameLine("a a", "a", "a")) } +func TestExecuteReturnsStatusAndKeepsTerminalErrorsOnStderr(t *testing.T) { + badEnvFile := filepath.Join(t.TempDir(), "invalid.yaml") + require.NoError(t, os.WriteFile(badEnvFile, []byte("invalid: ["), 0o600)) + t.Run("help succeeds without terminal rendering", func(t *testing.T) { + res := NewCommandHarness(t).Execute("--help") + assert.NoError(t, res.Err) + assert.Zero(t, res.Runtime.ExitStatus) + assert.Empty(t, res.Stderr.String()) + assert.Contains(t, res.Stdout.String(), "Usage:") + }) + + for _, test := range []struct { + name string + args []string + wantUsage bool + }{ + {name: "parse failure", args: []string{"workflow", "describe", "--not-a-flag"}, wantUsage: true}, + {name: "required flag failure", args: []string{"workflow", "describe"}, wantUsage: true}, + {name: "pre-run/configuration failure", args: []string{"workflow", "list", "--env-file", badEnvFile}}, + {name: "runtime failure", args: []string{"config", "get", "--disable-config-file", "--disable-config-env"}}, + } { + t.Run(test.name, func(t *testing.T) { + res := NewCommandHarness(t).Execute(test.args...) + assert.Error(t, res.Err) + assert.Equal(t, 1, res.Runtime.ExitStatus) + assert.Equal(t, 1, strings.Count(res.Stderr.String(), "Error:")) + assert.Equal(t, test.wantUsage, strings.Contains(res.Stderr.String(), "Usage:")) + assert.Empty(t, res.Stdout.String()) + }) + } +} + func (h *CommandHarness) Eventually( condition func() bool, waitFor time.Duration, @@ -145,9 +179,10 @@ func (h *CommandHarness) T() *testing.T { } type CommandResult struct { - Err error - Stdout bytes.Buffer - Stderr bytes.Buffer + Err error + Runtime temporalcli.Result + Stdout bytes.Buffer + Stderr bytes.Buffer } func (h *CommandHarness) Execute(args ...string) *CommandResult { @@ -167,20 +202,13 @@ func (h *CommandHarness) Execute(args ...string) *CommandResult { if options.DeprecatedEnvConfig.DisableEnvConfig { options.DeprecatedEnvConfig.EnvConfigName = "default" } - // Capture error - options.Fail = func(err error) { - if res.Err != nil { - panic("fail called twice, just failed with " + err.Error()) - } - res.Err = err - } - // Run ctx, cancel := context.WithCancel(h.Context) h.t.Cleanup(cancel) defer cancel() h.t.Logf("Calling: %v", strings.Join(args, " ")) - temporalcli.Execute(ctx, options) + res.Runtime = temporalcli.Execute(ctx, options) + res.Err = res.Runtime.CommandErr if res.Stdout.Len() > 0 { h.t.Logf("Stdout:\n-----\n%s\n-----", &res.Stdout) } diff --git a/internal/temporalcli/connectdiag.go b/internal/temporalcli/connectdiag.go index 3a5593416..1009b135d 100644 --- a/internal/temporalcli/connectdiag.go +++ b/internal/temporalcli/connectdiag.go @@ -132,7 +132,11 @@ func diagnoseConnection(ctx context.Context, address string, tlsCfg *tls.Config, // Stage: gRPC — no re-dial; classify the original error. d.Cause, d.Detail = classifyGRPCError(origErr) - d.fail("gRPC connection failed: " + shortErr(origErr)) + if d.Cause == causeAuth { + d.fail("gRPC authentication failed") + } else { + d.fail("gRPC connection failed: " + shortErr(origErr)) + } return d } @@ -152,7 +156,8 @@ func (d *connectDiagnosis) probeTLS(ctx context.Context, conn net.Conn, host str } // Handshake OK; a short read distinguishes an mTLS rejection alert from a // genuinely healthy connection (where the read just times out). - _ = tlsConn.SetReadDeadline(time.Now().Add(connectDiagnosisReadProbe)) + stopCancel := armReadDeadline(ctx, tlsConn) + defer stopCancel() buf := make([]byte, 1) _, err := tlsConn.Read(buf) if err != nil && !isTimeout(err) { @@ -164,6 +169,15 @@ func (d *connectDiagnosis) probeTLS(ctx context.Context, conn net.Conn, host str d.ok("TLS handshake succeeded") } +func armReadDeadline(ctx context.Context, conn net.Conn) func() bool { + readDeadline := time.Now().Add(connectDiagnosisReadProbe) + if contextDeadline, ok := ctx.Deadline(); ok && contextDeadline.Before(readDeadline) { + readDeadline = contextDeadline + } + _ = conn.SetReadDeadline(readDeadline) + return context.AfterFunc(ctx, func() { _ = conn.SetReadDeadline(time.Now()) }) +} + func (d *connectDiagnosis) classifyTLSError(err error) { var recordErr tls.RecordHeaderError var certErr *tls.CertificateVerificationError @@ -232,7 +246,7 @@ func classifyGRPCError(err error) (connectCause, string) { if st, ok := status.FromError(unwrapped); ok { switch st.Code() { case codes.Unauthenticated, codes.PermissionDenied: - return causeAuth, st.Message() + return causeAuth, "" case codes.DeadlineExceeded: return causeTimeout, "" } diff --git a/internal/temporalcli/connectdiag_test.go b/internal/temporalcli/connectdiag_test.go index 456528747..babb900f0 100644 --- a/internal/temporalcli/connectdiag_test.go +++ b/internal/temporalcli/connectdiag_test.go @@ -8,10 +8,13 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "errors" "fmt" + "io" "math/big" "net" "strings" + "sync" "testing" "time" @@ -227,10 +230,9 @@ func TestClassifyGRPCError(t *testing.T) { cause: causeTimeout, }, { - name: "unauthenticated status", - err: fmt.Errorf("failed reaching server: %w", status.Error(codes.Unauthenticated, "bad credentials")), - cause: causeAuth, - detail: "bad credentials", + name: "unauthenticated status", + err: fmt.Errorf("failed reaching server: %w", status.Error(codes.Unauthenticated, "bad credentials")), + cause: causeAuth, }, { name: "permission denied status", @@ -279,10 +281,47 @@ func TestConnectSummary(t *testing.T) { } } +func TestAuthSummaryDoesNotCopyServerControlledStatusMessage(t *testing.T) { + d := &connectDiagnosis{Cause: causeAuth, Detail: "attacker text\nrun this"} + assert.Equal(t, "authentication failed", connectSummary(d, errors.New("fallback attacker text"))) +} + +func TestArmReadDeadlineUsesEarlierContextDeadlineAndCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + defer cancel() + conn := &deadlineRecordingConn{} + stop := armReadDeadline(ctx, conn) + defer stop() + require.Eventually(t, func() bool { return !conn.deadline().IsZero() }, time.Second, time.Millisecond) + assert.WithinDuration(t, time.Now().Add(50*time.Millisecond), conn.deadline(), 25*time.Millisecond) + cancel() + require.Eventually(t, func() bool { return time.Until(conn.deadline()) <= 0 }, time.Second, time.Millisecond) +} + +type deadlineRecordingConn struct { + mu sync.Mutex + d time.Time +} + +func (c *deadlineRecordingConn) Read([]byte) (int, error) { return 0, io.EOF } +func (c *deadlineRecordingConn) Write(p []byte) (int, error) { return len(p), nil } +func (c *deadlineRecordingConn) Close() error { return nil } +func (c *deadlineRecordingConn) LocalAddr() net.Addr { return nil } +func (c *deadlineRecordingConn) RemoteAddr() net.Addr { return nil } +func (c *deadlineRecordingConn) SetDeadline(time.Time) error { return nil } +func (c *deadlineRecordingConn) SetWriteDeadline(time.Time) error { return nil } +func (c *deadlineRecordingConn) SetReadDeadline(d time.Time) error { + c.mu.Lock() + c.d = d + c.mu.Unlock() + return nil +} +func (c *deadlineRecordingConn) deadline() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.d } + func TestSuggestFix(t *testing.T) { cloudMeta := connectMeta{ - Args: []string{"workflow", "list", "--address", "foo.bar.tmprl.cloud:7233"}, - Address: "foo.bar.tmprl.cloud:7233", + CommandPath: []string{"workflow", "list"}, + Address: "foo.bar.tmprl.cloud:7233", } tests := []struct { name string @@ -294,13 +333,13 @@ func TestSuggestFix(t *testing.T) { name: "mTLS on cloud endpoint", diag: &connectDiagnosis{Cause: causeClientCertRequired}, meta: cloudMeta, - contains: []string{"Temporal Cloud", "--tls-cert-path YourCert.pem", "--tls-key-path YourKey.pem", "temporal config set --prop tls.client_cert_path"}, + contains: []string{"Temporal Cloud", "tls.client_cert_path --value YourCert.pem", "tls.client_key_path --value YourKey.pem"}, }, { name: "mTLS on generic endpoint", diag: &connectDiagnosis{Cause: causeClientCertRequired}, - meta: connectMeta{Args: []string{"workflow", "list"}, Address: "myhost:7233"}, - contains: []string{"requires client certificates", "--tls-cert-path YourCert.pem"}, + meta: connectMeta{CommandPath: []string{"workflow", "list"}, Address: "myhost:7233"}, + contains: []string{"requires client certificates", "tls.client_cert_path --value YourCert.pem"}, }, { name: "refused on local default port", @@ -318,13 +357,13 @@ func TestSuggestFix(t *testing.T) { name: "dns failure from profile address", diag: &connectDiagnosis{Cause: causeDNS}, meta: connectMeta{Address: "typo.example.com:7233", AddressSource: "profile", ProfileName: "prod"}, - contains: []string{`Could not resolve "typo.example.com"`, `profile "prod"`, "temporal config get --prop address"}, + contains: []string{`Could not resolve "typo.example.com"`, `profile "prod"`, "temporal config get --prop address --profile prod"}, }, { name: "server speaks TLS", diag: &connectDiagnosis{Cause: causeServerSpeaksTLS}, - meta: connectMeta{Args: []string{"workflow", "list"}, Address: "myhost:7233"}, - contains: []string{"Add --tls"}, + meta: connectMeta{CommandPath: []string{"workflow", "list"}, Address: "myhost:7233"}, + contains: []string{"requires TLS", "temporal config set --prop tls --value true"}, }, { name: "server plaintext", @@ -336,7 +375,7 @@ func TestSuggestFix(t *testing.T) { name: "api key rejected", diag: &connectDiagnosis{Cause: causeAuth}, meta: connectMeta{Address: "us-west-2.aws.api.temporal.io:7233", HasAPIKey: true}, - contains: []string{"rejected the provided API key"}, + contains: []string{"rejected the configured API key"}, }, { name: "cert file unreadable", @@ -347,7 +386,7 @@ func TestSuggestFix(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := suggestFix(tt.diag, tt.meta) + got := string(renderErrorText(errorReport{Summary: "failure", Action: suggestAction(tt.diag, tt.meta)}, renderOptions{})) for _, want := range tt.contains { assert.Contains(t, got, want) } @@ -355,19 +394,6 @@ func TestSuggestFix(t *testing.T) { } } -func TestReconstructCommand(t *testing.T) { - got := reconstructCommand( - []string{"workflow", "list", "--address", "foo:7233", "--query", "WorkflowType = 'x'"}, - "--tls-cert-path YourCert.pem", - ) - assert.Equal(t, - "temporal workflow list \\\n"+ - " --address foo:7233 \\\n"+ - " --query \"WorkflowType = 'x'\" \\\n"+ - " --tls-cert-path YourCert.pem", - got) -} - func TestConnectErrorRendering(t *testing.T) { origErr := fmt.Errorf("failed reaching server: context deadline exceeded") d := &connectDiagnosis{ @@ -380,16 +406,16 @@ func TestConnectErrorRendering(t *testing.T) { }, } err := newConnectError(d, connectMeta{ - Args: []string{"workflow", "list", "--address", "foo.bar.tmprl.cloud:7233"}, - Address: "foo.bar.tmprl.cloud:7233", + CommandPath: []string{"workflow", "list"}, + Address: "foo.bar.tmprl.cloud:7233", }, origErr) - msg := err.Error() + msg := string(renderErrorText(err.report(), renderOptions{})) assert.Contains(t, msg, "failed connecting to Temporal server at foo.bar.tmprl.cloud:7233: TLS handshake failed: server requires client certificate (mTLS)") assert.Contains(t, msg, "Connecting to foo.bar.tmprl.cloud:7233") assert.Contains(t, msg, "✓ DNS resolved (3 addresses)") assert.Contains(t, msg, "✗ TLS handshake failed") - assert.Contains(t, msg, "--tls-cert-path YourCert.pem") + assert.Contains(t, msg, "tls.client_cert_path --value YourCert.pem") // Unwrap preserves the original error. assert.ErrorContains(t, err.Unwrap(), "context deadline exceeded") } diff --git a/internal/temporalcli/connecterror.go b/internal/temporalcli/connecterror.go index 76866cf0d..5f01086f6 100644 --- a/internal/temporalcli/connecterror.go +++ b/internal/temporalcli/connecterror.go @@ -4,60 +4,57 @@ import ( "fmt" "net" "strings" - - "github.com/fatih/color" ) // connectError is returned by dialClient when connecting to the server fails. -// Error() renders the full multi-line diagnosis (summary, probe stages, and a -// suggested fix); Unwrap() preserves the original dial error for -// errors.Is/As. +// It retains semantic observations for terminal normalization; Error stays a +// concise, uncolored compatibility string and Unwrap preserves the cause. type connectError struct { - rendered string - cause error + diagnosis connectDiagnosis + meta connectMeta + cause error } -func (e *connectError) Error() string { return e.rendered } +func (e *connectError) Error() string { + return fmt.Sprintf("failed connecting to Temporal server at %s: %s", e.meta.Address, connectSummary(&e.diagnosis, e.cause)) +} func (e *connectError) Unwrap() error { return e.cause } -// connectMeta carries the request context the suggestion engine needs to -// propose a concrete fix (e.g. reconstructing the exact command the user -// typed with the missing flags appended). +// connectMeta contains only allowlisted connection provenance. Raw argv and +// credential values must never enter this value. type connectMeta struct { - // Args are the CLI args as typed (without the binary name). - Args []string + // CommandPath is retained only as non-sensitive provenance for callers that + // still populate it. Suggestions never replay the current command. + CommandPath []string Address string AddressSource string // "flag", "profile", or "default" ProfileName string HasAPIKey bool + HasOAuth bool TLSConfigured bool } -// newConnectError renders a connection failure into a connectError. It must -// be called while command execution is active so that color state (JSON mode, -// --color) is applied correctly; the rendering is captured eagerly. func newConnectError(d *connectDiagnosis, meta connectMeta, origErr error) *connectError { - var b strings.Builder - b.WriteString("failed connecting to Temporal server at ") - b.WriteString(meta.Address) - b.WriteString(": ") - b.WriteString(connectSummary(d, origErr)) - if len(d.Stages) > 0 { - b.WriteString("\n\n Connecting to ") - b.WriteString(meta.Address) - for _, stage := range d.Stages { - if stage.Status == diagOK { - b.WriteString("\n " + color.GreenString("✓") + " " + stage.Label) - } else { - b.WriteString("\n " + color.RedString("✗") + " " + stage.Label) - } - } + if meta.Address == "" { + meta.Address = d.Address + } + return &connectError{diagnosis: *d, meta: meta, cause: origErr} +} + +func (e *connectError) report() errorReport { + report := errorReport{ + Summary: e.Error(), + CheckHeading: "Connecting to " + e.meta.Address, + Action: suggestAction(&e.diagnosis, e.meta), } - if suggestion := suggestFix(d, meta); suggestion != "" { - b.WriteString("\n\n") - b.WriteString(indentLines(suggestion, " ")) + for _, stage := range e.diagnosis.Stages { + outcome := checkFailed + if stage.Status == diagOK { + outcome = checkSucceeded + } + report.Checks = append(report.Checks, errorCheck{Outcome: outcome, Message: stage.Label}) } - return &connectError{rendered: b.String(), cause: origErr} + return report } // connectSummary is the one-line cause appended to the first error line. For @@ -72,7 +69,7 @@ func connectSummary(d *connectDiagnosis, origErr error) string { case causeTCPTimeout: return "connection timed out" case causeServerPlaintext: - return "TLS is enabled but the server did not respond with TLS" + return "TLS is enabled but the server did not respond with TLS; check tls configuration" case causeServerSpeaksTLS: return "the server requires TLS but the CLI is connecting without it" case causeClientCertRequired: @@ -84,54 +81,49 @@ func connectSummary(d *connectDiagnosis, origErr error) string { case causeCertFileUnreadable: return fmt.Sprintf("cannot read file %q", d.Detail) case causeAuth: - if d.Detail != "" { - return "authentication failed: " + d.Detail - } return "authentication failed" default: return shortErr(origErr) } } -// suggestFix maps a classified failure to one concrete next step. First match -// wins; an empty return means no suggestion (the stages still render). -func suggestFix(d *connectDiagnosis, meta connectMeta) string { +// suggestAction maps a classified failure to one typed next step. Invocation +// arguments are known literals plus safe command metadata, never raw argv. +func suggestAction(d *connectDiagnosis, meta connectMeta) *displayAction { host, port, _ := net.SplitHostPort(meta.Address) switch d.Cause { case causeCertFileUnreadable: - return fmt.Sprintf("Cannot read %q — check that the path exists and is readable.", d.Detail) + return &displayAction{Label: fmt.Sprintf("Cannot read %q — check that the path exists and is readable.", d.Detail)} case causeClientCertRequired: - var b strings.Builder + message := "The server requires client certificates (mTLS). Configure both certificate paths:" if isCloudHost(host) { - b.WriteString("This looks like a Temporal Cloud endpoint secured with mTLS. Provide client certificates:") - } else { - b.WriteString("The server requires client certificates (mTLS). Provide them:") + message = "This looks like a Temporal Cloud endpoint secured with mTLS. Provide client certificates:" } - b.WriteString("\n\n") - b.WriteString(indentLines(reconstructCommand(meta.Args, "--tls-cert-path YourCert.pem", "--tls-key-path YourKey.pem"), " ")) - b.WriteString("\n\nOr configure once:\n\n") - b.WriteString(" temporal config set --prop tls.client_cert_path YourCert.pem\n") - b.WriteString(" temporal config set --prop tls.client_key_path YourKey.pem") if strings.HasSuffix(host, ".api.temporal.io") { - b.WriteString("\n\nIf your namespace uses API-key auth instead, pass --api-key YourApiKey.") + message += " If the namespace uses API-key auth instead, pass --api-key." } - return b.String() + return configAction(message, meta, + []string{"--prop", "tls.client_cert_path", "--value", "YourCert.pem"}, + []string{"--prop", "tls.client_key_path", "--value", "YourKey.pem"}) case causeAuth: + if meta.HasAPIKey && meta.HasOAuth { + return &displayAction{Label: "The server rejected the configured API key or OAuth credentials. Verify the active credential and namespace gRPC endpoint."} + } if meta.HasAPIKey { - return "The server rejected the provided API key. Verify the key is valid and that the address is your namespace's gRPC endpoint (for Temporal Cloud API keys, a regional endpoint like us-west-2.aws.api.temporal.io:7233)." + return &displayAction{Label: "The server rejected the configured API key. Verify it and the namespace gRPC endpoint."} + } + if meta.HasOAuth { + return &displayAction{Label: "The server rejected the configured OAuth credentials. Verify them and the namespace gRPC endpoint."} } - return "The server rejected the request as unauthenticated. If it requires an API key, pass --api-key; if it requires mTLS, pass --tls-cert-path and --tls-key-path." + return &displayAction{Label: "The server rejected the request as unauthenticated. Configure an API key or mTLS credentials."} case causeServerPlaintext: - return fmt.Sprintf("The server at %s does not appear to use TLS. Remove --tls and certificate flags, or double-check the address and port.", meta.Address) + return &displayAction{Label: fmt.Sprintf("The server at %s does not appear to use TLS. Remove TLS settings or check the address.", meta.Address)} case causeServerSpeaksTLS: - var b strings.Builder - b.WriteString("The server requires TLS. Add --tls:") - b.WriteString("\n\n") - b.WriteString(indentLines(reconstructCommand(meta.Args, "--tls"), " ")) + message := "The server requires TLS. Add --tls:" if isCloudHost(host) { - b.WriteString("\n\nTemporal Cloud endpoints also need client credentials: --tls-cert-path/--tls-key-path or --api-key.") + message += " Temporal Cloud also requires client credentials." } - return b.String() + return configAction(message, meta, []string{"--prop", "tls", "--value", "true"}) case causeDNS: s := fmt.Sprintf("Could not resolve %q — check the server address.", host) if meta.AddressSource == "profile" { @@ -139,22 +131,42 @@ func suggestFix(d *connectDiagnosis, meta connectMeta) string { if profile == "" { profile = "default" } - s += fmt.Sprintf("\nThe address comes from config profile %q — inspect it with:\n\n temporal config get --prop address", profile) + s += fmt.Sprintf(" The address comes from config profile %q.", profile) + return &displayAction{Label: s, Invocations: []displayInvocation{{Command: []string{"temporal", "config", "get"}, Args: appendProfile([]string{"--prop", "address"}, profile)}}} } - return s + return &displayAction{Label: s} case causeTCPRefused: if isLoopbackHost(host) && port == "7233" { - return fmt.Sprintf("No Temporal server is running at %s. Start a local dev server with:\n\n temporal server start-dev", meta.Address) + return &displayAction{Label: fmt.Sprintf("No Temporal server is running at %s. Start a local dev server:", meta.Address), Invocations: []displayInvocation{{Command: []string{"temporal", "server", "start-dev"}}}} } - return fmt.Sprintf("Nothing is listening at %s — verify the address and that the server is running.", meta.Address) + return &displayAction{Label: fmt.Sprintf("Nothing is listening at %s — verify the address and that the server is running.", meta.Address)} case causeCAVerify: - return "The server's TLS certificate is not trusted by your system roots. If the server uses a private CA, pass --tls-ca-path YourServerCA.pem." + return configAction("The server certificate is not trusted. Configure its private CA:", meta, []string{"--prop", "tls.server_ca_cert_path", "--value", "YourServerCA.pem"}) case causeHostnameMismatch: - return fmt.Sprintf("The server's TLS certificate is not valid for %q. If you connect via an IP or alternate name, set --tls-server-name to the name in the certificate.", host) + return &displayAction{Label: fmt.Sprintf("The server certificate is not valid for %q. Set --tls-server-name to a certificate name.", host)} case causeTCPTimeout, causeTimeout: - return fmt.Sprintf("The connection stalled — a firewall or proxy may be blocking traffic to %s. Verify the address, port, and network path. Bound the wait with --client-connect-timeout.", meta.Address) + return &displayAction{Label: fmt.Sprintf("The connection stalled. Verify the address and network path to %s.", meta.Address)} + } + return nil +} + +func configAction(label string, meta connectMeta, propertyArgs ...[]string) *displayAction { + action := &displayAction{Label: label} + for _, args := range propertyArgs { + action.Invocations = append(action.Invocations, displayInvocation{ + Command: []string{"temporal", "config", "set"}, + Args: appendProfile(args, meta.ProfileName), + }) } - return "" + return action +} + +func appendProfile(args []string, profile string) []string { + result := append([]string(nil), args...) + if profile != "" { + result = append(result, "--profile", profile) + } + return result } func isCloudHost(host string) bool { @@ -169,42 +181,6 @@ func isLoopbackHost(host string) bool { return ip != nil && ip.IsLoopback() } -// reconstructCommand re-renders the command the user typed, one option per -// line (matching the style used in help text), with extraFlags appended. -func reconstructCommand(args []string, extraFlags ...string) string { - // Split leading command words from options. - var words, opts []string - for i, arg := range args { - if strings.HasPrefix(arg, "-") { - opts = args[i:] - break - } - words = append(words, arg) - } - var lines []string - lines = append(lines, "temporal "+strings.Join(words, " ")) - for i := 0; i < len(opts); i++ { - line := opts[i] - // Attach the option's value, if the next arg isn't another option. - if !strings.Contains(line, "=") && i+1 < len(opts) && !strings.HasPrefix(opts[i+1], "-") { - i++ - line += " " + quoteArg(opts[i]) - } - lines = append(lines, " "+line) - } - for _, flag := range extraFlags { - lines = append(lines, " "+flag) - } - return strings.Join(lines, " \\\n") -} - -func quoteArg(arg string) string { - if strings.ContainsAny(arg, " \t\"'") { - return fmt.Sprintf("%q", arg) - } - return arg -} - func indentLines(s, indent string) string { lines := strings.Split(s, "\n") for i, line := range lines { diff --git a/internal/temporalcli/terminal.go b/internal/temporalcli/terminal.go new file mode 100644 index 000000000..cb0ecaf4f --- /dev/null +++ b/internal/temporalcli/terminal.go @@ -0,0 +1,276 @@ +package temporalcli + +import ( + "errors" + "fmt" + "io" + "runtime" + "strings" + "unicode" +) + +const defaultFailureExitStatus = 1 + +// Result describes the terminal outcome without exiting the process. The +// original command error remains separate from any failure writing stderr. +type Result struct { + CommandErr error + PresentationErr error + ExitStatus int +} + +type checkOutcome int + +const ( + checkSucceeded checkOutcome = iota + checkFailed +) + +type safeField struct { + Label string + Value string +} + +type errorCheck struct { + Outcome checkOutcome + Message string +} + +type displayInvocation struct { + Command []string + Args []string +} + +type displayAction struct { + Label string + Invocations []displayInvocation +} + +type errorReport struct { + Summary string + Context []safeField + CheckHeading string + Checks []errorCheck + Action *displayAction + Usage string +} + +type renderOptions struct { + Color bool + Shell displayShell +} + +type displayShell int + +const ( + displayShellPOSIX displayShell = iota + displayShellPowerShell +) + +type terminalOptions struct { + Stderr io.Writer + Color bool + KnownSecrets []string + Usage string + DisplayErr error +} + +type commandErrorRecorder struct { + err error +} + +func (r *commandErrorRecorder) Record(err error) { + if r.err == nil { + r.err = err + } + panic(recordedCommandError{}) +} + +func (r *commandErrorRecorder) Err() error { return r.err } + +type recordedCommandError struct{} + +func runRecordedCommand(run func()) (panicked bool) { + defer func() { + if recovered := recover(); recovered != nil { + if _, ok := recovered.(recordedCommandError); !ok { + panic(recovered) + } + panicked = true + } + }() + run() + return false +} + +func handleTerminalError(commandErr error, options terminalOptions) Result { + result := Result{CommandErr: commandErr, ExitStatus: defaultFailureExitStatus} + displayErr := options.DisplayErr + if displayErr == nil { + displayErr = commandErr + } + report := normalizeError(displayErr) + report.Usage = options.Usage + report = redactReport(report, options.KnownSecrets) + shell := displayShellPOSIX + if runtime.GOOS == "windows" { + shell = displayShellPowerShell + } + rendered := renderErrorText(report, renderOptions{Color: options.Color, Shell: shell}) + n, err := options.Stderr.Write(rendered) + if err == nil && n != len(rendered) { + err = io.ErrShortWrite + } + result.PresentationErr = err + return result +} + +func normalizeError(err error) errorReport { + var connectionErr *connectError + if errors.As(err, &connectionErr) { + return connectionErr.report() + } + var activityErr *activityNotFoundError + if errors.As(err, &activityErr) { + return activityErr.report() + } + if err == nil || err.Error() == "" { + return errorReport{Summary: "unknown error"} + } + return errorReport{Summary: err.Error()} +} + +func redactReport(report errorReport, secrets []string) errorReport { + // This is defense in depth for exact values already known to the runtime, + // not a claim that arbitrary legacy error prose is generically sanitized. + // Structured adapters remain responsible for admitting only safe fields. + redact := func(value string) string { + for _, secret := range secrets { + if secret != "" { + value = strings.ReplaceAll(value, secret, "[REDACTED]") + } + } + return value + } + report.Summary = redact(report.Summary) + report.Usage = redact(report.Usage) + for i := range report.Context { + report.Context[i].Value = redact(report.Context[i].Value) + } + for i := range report.Checks { + report.Checks[i].Message = redact(report.Checks[i].Message) + } + if report.Action != nil { + report.Action.Label = redact(report.Action.Label) + for invocationIndex := range report.Action.Invocations { + invocation := &report.Action.Invocations[invocationIndex] + for i := range invocation.Command { + invocation.Command[i] = redact(invocation.Command[i]) + } + for i := range invocation.Args { + invocation.Args[i] = redact(invocation.Args[i]) + } + } + } + return report +} + +// renderErrorText is total over errorReport and performs no I/O or global +// color lookup. Reports are redacted before reaching this function. +func renderErrorText(report errorReport, options renderOptions) []byte { + if report.Summary == "" { + report.Summary = "unknown error" + } + var b strings.Builder + b.WriteString("Error: ") + b.WriteString(report.Summary) + b.WriteByte('\n') + for _, field := range report.Context { + fmt.Fprintf(&b, " %s: %s\n", field.Label, field.Value) + } + if len(report.Checks) > 0 { + heading := report.CheckHeading + if heading == "" { + heading = "Connecting" + } + fmt.Fprintf(&b, "\n %s\n", heading) + for _, check := range report.Checks { + symbol := "✓" + colorCode := "32" + if check.Outcome == checkFailed { + symbol = "✗" + colorCode = "31" + } + if options.Color { + symbol = "\x1b[" + colorCode + "m" + symbol + "\x1b[0m" + } + fmt.Fprintf(&b, " %s %s\n", symbol, check.Message) + } + } + if report.Action != nil { + b.WriteByte('\n') + if report.Action.Label != "" { + b.WriteString(indentLines(report.Action.Label, " ")) + b.WriteByte('\n') + } + for _, invocation := range report.Action.Invocations { + rendered, ok := renderInvocation(invocation, options.Shell) + if !ok { + continue + } + b.WriteByte('\n') + b.WriteString(" ") + b.WriteString(rendered) + b.WriteByte('\n') + } + } + if report.Usage != "" { + b.WriteByte('\n') + b.WriteString(strings.TrimLeft(report.Usage, "\n")) + if !strings.HasSuffix(report.Usage, "\n") { + b.WriteByte('\n') + } + } + return []byte(b.String()) +} + +func renderInvocation(invocation displayInvocation, shell displayShell) (string, bool) { + parts := append([]string(nil), invocation.Command...) + parts = append(parts, invocation.Args...) + if len(parts) == 0 { + return "", false + } + for i := range parts { + if containsControl(parts[i]) { + return "", false + } + if shell == displayShellPowerShell { + parts[i] = quotePowerShell(parts[i]) + } else { + parts[i] = quotePOSIX(parts[i]) + } + } + return strings.Join(parts, " "), true +} + +func containsControl(value string) bool { + return strings.IndexFunc(value, unicode.IsControl) >= 0 +} + +func quotePOSIX(value string) string { + if value != "" && strings.IndexFunc(value, func(r rune) bool { + return !(unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("_@%+=:,./-", r)) + }) < 0 { + return value + } + return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" +} + +func quotePowerShell(value string) string { + if value != "" && strings.IndexFunc(value, func(r rune) bool { + return !(unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("_@%+=:,./-", r)) + }) < 0 { + return value + } + return "'" + strings.ReplaceAll(value, "'", "''") + "'" +} diff --git a/internal/temporalcli/terminal_test.go b/internal/temporalcli/terminal_test.go new file mode 100644 index 000000000..f9efe71a3 --- /dev/null +++ b/internal/temporalcli/terminal_test.go @@ -0,0 +1,268 @@ +package temporalcli + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "testing" + + "github.com/fatih/color" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/temporalio/cli/cliext" + "golang.org/x/oauth2" +) + +func TestHandleTerminalErrorWritesOneRedactedReportAndRetainsBothErrors(t *testing.T) { + commandErr := errors.New("request rejected for token exact-secret") + writeErr := errors.New("stderr closed") + writer := &countingErrorWriter{err: writeErr} + + result := handleTerminalError(commandErr, terminalOptions{ + Stderr: writer, + KnownSecrets: []string{"exact-secret"}, + }) + + assert.Equal(t, 1, result.ExitStatus) + assert.ErrorIs(t, result.CommandErr, commandErr) + assert.ErrorIs(t, result.PresentationErr, writeErr) + assert.Equal(t, 1, writer.writes) + assert.Contains(t, string(writer.last), "Error: request rejected for token [REDACTED]") + assert.NotContains(t, string(writer.last), "exact-secret") +} + +func TestRenderErrorTextIsTotalAndUsesExplicitColorPolicy(t *testing.T) { + report := errorReport{ + Summary: "connection refused", + Checks: []errorCheck{{ + Outcome: checkFailed, + Message: "TCP connection refused", + }}, + } + + plain := string(renderErrorText(report, renderOptions{Color: false})) + colored := string(renderErrorText(report, renderOptions{Color: true})) + + assert.Equal(t, "Error: connection refused\n\n Connecting\n ✗ TCP connection refused\n", plain) + assert.NotContains(t, plain, "\x1b[") + assert.Contains(t, colored, "\x1b[") + assert.Equal(t, []byte("Error: unknown error\n"), renderErrorText(errorReport{}, renderOptions{})) +} + +func TestConnectErrorCarriesSemanticFactsWithoutRenderedStateOrRawArguments(t *testing.T) { + cause := errors.New("dial failed") + diagnosis := &connectDiagnosis{ + Address: "127.0.0.1:7233", + Cause: causeTCPRefused, + Stages: []diagStage{{Status: diagFail, Label: "TCP connection refused"}}, + } + err := newConnectError(diagnosis, connectMeta{Address: diagnosis.Address}, cause) + + assert.Equal(t, "failed connecting to Temporal server at 127.0.0.1:7233: connection refused", err.Error()) + assert.ErrorIs(t, err, cause) + report := normalizeError(err) + require.NotNil(t, report.Action) + require.Len(t, report.Action.Invocations, 1) + assert.Equal(t, []string{"temporal", "server", "start-dev"}, report.Action.Invocations[0].Command) + assert.NotContains(t, err.Error(), "\n") +} + +func TestRecorderSeamCapturesFirstLeafFailureWithoutChangingItsIdentity(t *testing.T) { + // Seam decision: generated Run callbacks already call Fail as their final + // operation. A command-scoped recorder therefore captures every leaf error + // without changing generated Run to RunE. RunE was rejected because Cobra + // skips post-run cleanup when RunE returns an error. + want := errors.New("leaf failure") + var recorder commandErrorRecorder + + assert.True(t, runRecordedCommand(func() { recorder.Record(want) })) + + assert.ErrorIs(t, recorder.Err(), want) +} + +func TestRecorderSeamStopsExecutionAfterFailureAndRunsCleanup(t *testing.T) { + var recorder commandErrorRecorder + workedAfterFailure := false + cleanupRan := false + func() { + defer func() { cleanupRan = true }() + assert.True(t, runRecordedCommand(func() { + recorder.Record(errors.New("stop here")) + workedAfterFailure = true + })) + }() + assert.False(t, workedAfterFailure) + assert.True(t, cleanupRan) +} + +func TestRenderInvocationUsesExplicitShellQuotingAndRejectsControls(t *testing.T) { + invocation := displayInvocation{Command: []string{"temporal", "config", "set"}, Args: []string{"--value", "space and 'quote'", "--profile", "-prod"}} + posix, ok := renderInvocation(invocation, displayShellPOSIX) + require.True(t, ok) + assert.Contains(t, posix, `'space and '"'"'quote'"'"''`) + assert.Contains(t, posix, "--profile -prod") + powerShell, ok := renderInvocation(invocation, displayShellPowerShell) + require.True(t, ok) + assert.Contains(t, powerShell, `'space and ''quote'''`) + _, ok = renderInvocation(displayInvocation{Command: []string{"temporal"}, Args: []string{"unsafe\nvalue"}}, displayShellPOSIX) + assert.False(t, ok) +} + +func TestFinishCommandPreservesOriginalErrorWhilePresentingCancellationCause(t *testing.T) { + original := errors.New("original command failure") + ctx, cancel := context.WithCancelCause(t.Context()) + cancel(errors.New("command timed out after 2s")) + var stderr bytes.Buffer + cctx := &CommandContext{Context: ctx, Options: CommandOptions{IOStreams: IOStreams{Stderr: &stderr}, EnvLookup: testEnvLookup{}}} + result := finishCommand(cctx, original, "") + assert.ErrorIs(t, result.CommandErr, original) + assert.Contains(t, stderr.String(), "command timed out after 2s") + assert.NotContains(t, stderr.String(), original.Error()) +} + +func TestRecorderSeamGeneratedLeavesStopAfterRecording(t *testing.T) { + source, err := os.ReadFile("commands.gen.go") + require.NoError(t, err) + allCalls := bytes.Count(source, []byte("cctx.Options.Fail(err)")) + terminalCalls := bytes.Count(source, []byte("cctx.Options.Fail(err)\n\t\t}")) + + assert.Greater(t, allCalls, 100, "expected to inspect every generated command leaf") + assert.Equal(t, allCalls, terminalCalls, "every generated Fail call must be the final operation in its branch") +} + +func TestExecuteRestoresColorAcrossFailureSources(t *testing.T) { + old := color.NoColor + t.Cleanup(func() { color.NoColor = old }) + + for name, args := range map[string][]string{ + "argument": {"workflow", "describe", "--color", "always"}, + "pre-run": {"workflow", "list", "--time-format", "invalid", "--color", "always"}, + "runtime": {"config", "get", "--disable-config-file", "--disable-config-env", "--color", "always"}, + } { + t.Run(name, func(t *testing.T) { + color.NoColor = true + var stdout, stderr bytes.Buffer + result := Execute(t.Context(), CommandOptions{ + Args: args, + IOStreams: IOStreams{Stdout: &stdout, Stderr: &stderr}, + DeprecatedEnvConfig: DeprecatedEnvConfig{DisableEnvConfig: true}, + }) + assert.Error(t, result.CommandErr) + assert.True(t, color.NoColor, "global color policy must be restored") + }) + } +} + +func TestEarlyJSONPolicyDisablesTerminalColor(t *testing.T) { + cctx := &CommandContext{Options: CommandOptions{Args: []string{"workflow", "describe", "--output=jsonl", "--color=always"}}} + assert.False(t, cctx.terminalColorEnabled()) +} + +func TestUnknownFlagWithFollowingValueIsNotMisclassifiedAsUnknownCommand(t *testing.T) { + var stdout, stderr bytes.Buffer + result := Execute(t.Context(), CommandOptions{ + Args: []string{"--definitely-invalid", "value"}, + IOStreams: IOStreams{Stdout: &stdout, Stderr: &stderr}, + DeprecatedEnvConfig: DeprecatedEnvConfig{DisableEnvConfig: true}, + }) + require.Error(t, result.CommandErr) + assert.Contains(t, result.CommandErr.Error(), "unknown flag: --definitely-invalid") + assert.NotContains(t, result.CommandErr.Error(), "unknown command") +} + +func TestKnownSecretsExtractsAvailableScalarSliceHeaderAndEnvironmentValues(t *testing.T) { + cmd := &cobra.Command{} + cmd.Flags().String("api-key", "", "") + cmd.Flags().StringArray("grpc-meta", nil, "") + require.NoError(t, cmd.Flags().Parse([]string{"--api-key", "flag-secret", "--grpc-meta", "Authorization=header-secret"})) + cctx := &CommandContext{ + CurrentCommand: cmd, + Options: CommandOptions{EnvLookup: testEnvLookup{ + "TEMPORAL_CODEC_AUTH": "env-secret", + }}, + } + + secrets := cctx.knownSecrets() + assert.Contains(t, secrets, "flag-secret") + assert.Contains(t, secrets, "Authorization=header-secret") + assert.Contains(t, secrets, "header-secret") + assert.Contains(t, secrets, "env-secret") +} + +func TestKnownSecretsIgnoresDisabledConfigEnvironment(t *testing.T) { + cctx := &CommandContext{Options: CommandOptions{ + Args: []string{"--disable-config-env"}, + EnvLookup: testEnvLookup{"TEMPORAL_API_KEY": "disabled-env-secret"}, + }} + assert.NotContains(t, cctx.knownSecrets(), "disabled-env-secret") +} + +func TestCaptureEffectiveConnectionSecretsIncludesProfileValues(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "temporal.toml") + require.NoError(t, os.WriteFile(configPath, []byte(`[profile.prod] +api_key = "profile-api-secret" +codec = { endpoint = "https://codec.example", auth = "profile-codec-secret" } +grpc_meta = { authorization = "profile-header-secret" } +tls = { client_cert_data = "profile-cert-secret", client_key_data = "profile-key-secret", server_ca_cert_data = "profile-ca-secret" } +`), 0o600)) + cctx := &CommandContext{ + Options: CommandOptions{EnvLookup: testEnvLookup{}}, + RootCommand: &TemporalCommand{CommonOptions: cliext.CommonOptions{ + ConfigFile: configPath, Profile: "prod", DisableConfigEnv: true, + }}, + } + captureEffectiveConnectionSecrets(cctx, &cliext.ClientOptions{}) + for _, secret := range []string{"profile-api-secret", "profile-codec-secret", "profile-header-secret", "profile-cert-secret", "profile-key-secret", "profile-ca-secret"} { + assert.Contains(t, cctx.knownSecrets(), secret) + } + assert.True(t, cctx.hasAPIKey) +} + +func TestCaptureEffectiveConnectionSecretsIncludesOAuthValues(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "temporal.toml") + require.NoError(t, cliext.StoreClientOAuth(cliext.StoreClientOAuthOptions{ + ConfigFilePath: configPath, + ProfileName: "prod", + OAuth: &cliext.OAuthConfig{ + ClientConfig: &oauth2.Config{ClientID: "client", ClientSecret: "oauth-client-secret"}, + Token: &oauth2.Token{AccessToken: "oauth-access-secret", RefreshToken: "oauth-refresh-secret"}, + }, + })) + cctx := &CommandContext{ + Options: CommandOptions{EnvLookup: testEnvLookup{}}, + RootCommand: &TemporalCommand{CommonOptions: cliext.CommonOptions{ + ConfigFile: configPath, Profile: "prod", DisableConfigEnv: true, + }}, + } + captureEffectiveConnectionSecrets(cctx, &cliext.ClientOptions{}) + assert.ElementsMatch(t, []string{"oauth-client-secret", "oauth-access-secret", "oauth-refresh-secret"}, cctx.knownSecrets()) + assert.True(t, cctx.hasOAuth) + assert.False(t, cctx.hasAPIKey) +} + +type testEnvLookup map[string]string + +func (e testEnvLookup) LookupEnv(name string) (string, bool) { + value, ok := e[name] + return value, ok +} + +func (e testEnvLookup) Environ() []string { return nil } + +type countingErrorWriter struct { + err error + writes int + last []byte +} + +func (w *countingErrorWriter) Write(p []byte) (int, error) { + w.writes++ + w.last = append([]byte(nil), p...) + return 0, w.err +} + +var _ io.Writer = (*countingErrorWriter)(nil) From 880f6b9c418783775c9ee3230aa7f9bcd7e947ea Mon Sep 17 00:00:00 2001 From: Ross Nelson Date: Wed, 22 Jul 2026 17:15:08 -0400 Subject: [PATCH 5/9] Document structured error handling transition --- CONTRIBUTING.md | 10 + docs/structured-error-handling.md | 304 ++++++++++++++++++++++++++++++ 2 files changed, 314 insertions(+) create mode 100644 docs/structured-error-handling.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9eeea5b26..8170c4ed7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,10 @@ Uses normal `go test`, e.g.: go test ./... +The root command does not enter the nested `cliext` module. Run `(cd cliext && go test ./...)` separately. + +The current transition branch has a recorded standalone `cliext` compile mismatch. Until that mismatch is fixed, the nested command must match the baseline exception. Do not report it as green. + See other tests for how to leverage things like the command harness and dev server suite. Example to run a single test case: @@ -20,11 +24,17 @@ Example to run a single test case: ## Adding/updating commands +Follow [Structured Error Handling](docs/structured-error-handling.md) when a command adds or changes terminal failure behavior. + First, update [commands.yaml](internal/temporalcli/commands.yaml) following the rules in that file. Then to regenerate the [commands.gen.go](internal/temporalcli/commands.gen.go) file from code, run: go run ./cmd/gen-commands -input internal/temporalcli/commands.yaml -pkg temporalcli -context "*CommandContext" > internal/temporalcli/commands.gen.go +Generated commands currently inherit the existing `cctx.Options.Fail(err)` adapter. Regeneration may retain those calls until Phase A of [Structured Error Handling](docs/structured-error-handling.md#transition-gates) changes the generator. + +Do not add handwritten or direct `Fail` calls. Do not create a new generator pattern that emits them or copy the adapter into new code. Phase A removes generated `Fail` calls entirely. + This will expect every non-parent command to have a `run` method, so for new commands developers will have to implement `run` on the new command in a separate file before it will compile. diff --git a/docs/structured-error-handling.md b/docs/structured-error-handling.md new file mode 100644 index 000000000..873e97cdf --- /dev/null +++ b/docs/structured-error-handling.md @@ -0,0 +1,304 @@ +# Structured Error Handling + +PR [#1114](https://github.com/temporalio/cli/pull/1114) proves a useful error-handling direction, but transition work remains before it can become the repository standard. + +This guide defines the pattern that command families may adopt and the work needed to complete that transition. + +- **Last updated:** 2026-07-22 +- **Status:** transition architecture +- **Verdict:** PR [#1114](https://github.com/temporalio/cli/pull/1114) is a reference slice, not a finished general framework + +## What to adopt now + +Command and domain code should keep returning ordinary wrapped Go errors. The terminal boundary should convert those errors into safe display facts. It should also own one stderr render and the final status. + +Connection failures and Standalone Activity `NotFound` prove parts of this direction. However, the current branch still contains transition mechanisms that new code must not copy. + +- Adopt native wrapped errors, semantic domain facts, explicit terminal adapters, typed token actions, bounded diagnosis, and conservative fallback now. +- Do not copy the panic-based `Fail` recorder, duplicate config resolution, global color mutation, shallow redaction, family-owned report methods, or generic helper coupling. +- Do not add a provider interface yet. Keep explicit terminal adapters until three genuinely different families show a useful shared boundary. +- Do not roll the pattern across more command families until phases A through C in [Transition gates](#transition-gates) pass. + +Key files: + +- [`internal/temporalcli/terminal.go`](../internal/temporalcli/terminal.go) contains the transition report, renderer, recorder, and `Result`. +- [`internal/temporalcli/commands.go`](../internal/temporalcli/commands.go) owns command execution and final terminal handling. +- [`internal/temporalcli/connecterror.go`](../internal/temporalcli/connecterror.go) carries connection facts and actions in the reference slice. +- [`internal/temporalcli/connectdiag.go`](../internal/temporalcli/connectdiag.go) performs connection-specific diagnosis. +- [`internal/temporalcli/client.go`](../internal/temporalcli/client.go) resolves connection options and starts diagnosis after a failed dial. + +## Current flow is useful but transitional + +```mermaid +flowchart LR + A[Generated command callback] -->|calls Fail| B[Panic recorder] + B -->|native error| C[Terminal normalizer] + D[Failed Temporal dial] -->|DNS TCP TLS facts| E[Connection error] + E --> C + F[Activity polling] -->|typed not found error| C + C -->|mutable report| G[Exact-value redaction] + G -->|explicit render options| H[Text renderer] + H -->|one checked write| I[stderr] + C -->|Result and status| J[main] +``` + +The terminal boundary already returns a [`Result`](../internal/temporalcli/terminal.go). It separates `CommandErr` from `PresentationErr`. Started extensions keep their own output and child status. + +The renderer accepts explicit color and shell settings. However, command execution still mutates the package-level `color.NoColor` value. Concurrent `Execute` calls are therefore unsupported. + +Connection diagnosis is family-specific and runs only after a failure. It starts after the real dial fails and uses the command context. A three-second cap bounds its work. Set `TEMPORAL_CLI_DISABLE_CONNECT_DIAGNOSIS` to disable it. + +## How terminal failures flow + +### Reporting a built-in command failure + +**Trigger:** A built-in command reaches its final error. + +1. **Preserve the cause**: Command code returns the error or adds lowercase operation context with `%w`. +2. **Carry semantic facts**: A domain error stores stable facts that callers may inspect. It does not store color, indentation, or command text. +3. **Adapt at the terminal**: An explicit family adapter converts supported facts into controlled display fields. +4. **Render once**: The current terminal boundary exact-redacts registered secret values and renders one byte slice. It then attempts one checked stderr write. Phase D adds deep-copy and control sanitization for every display field. +5. **Return ownership**: `Result.CommandErr` keeps the error chain, `Result.PresentationErr` keeps a write failure, and `ExitStatus` carries the status. + +**When no adapter matches:** Preserve concise legacy text and omit context, checks, and actions that have not passed a family security review. + +### Diagnosing a failed connection + +**Trigger:** The real Temporal client dial fails while the command context is still active. + +1. **Check eligibility**: Skip diagnosis after cancellation or when the kill switch is set. +2. **Apply a family budget**: Bound the connection probe by the command deadline and a three-second maximum. +3. **Collect observations**: Run DNS, TCP, and TLS checks against the configured target. +4. **Avoid privileged retries**: Do not send an API key, OAuth credential, or Temporal RPC. A TLS handshake may present configured client certificates. +5. **Return facts**: Preserve the original dial error and attach only supported observations. + +**When diagnosis is unavailable:** Current behavior preserves the original error. The current model has no `unknown` or `skipped` observation state. Deadline classification also remains a known accuracy gap. Phase E adds those states and the related accuracy tests. + +A later probe must never be presented as proof of the original failure. + +### Passing through a started extension failure + +**Trigger:** A `temporal-*` extension starts and exits nonzero. + +1. **Keep child streams**: The extension owns its stdout and stderr. +2. **Return the child status**: The parent returns `ExtensionNonZeroExit` with the child exit code. +3. **Skip parent rendering**: The parent does not add another error report. + +**When the child never starts:** The parent owns the failure and uses the normal terminal path. + +## Ownership boundary for new code + +### Copy and adopt now + +- Return native Go errors through the call graph. +- Add context with `%w` so `errors.Is` and `errors.As` keep working. +- Put stable semantic facts and the wrapped cause in a small domain error when callers need typed behavior. +- Add an explicit terminal-owned adapter for each admitted family. +- Render actions from typed command and argument tokens. Use long flags in displayed commands. +- Keep diagnosis beside the failing operation, with a family budget, cancellation, documented effects, and a rollback control. +- Preserve a conservative unknown fallback. +- Write parent-owned terminal output once to stderr and return status explicitly. + +### Do not copy from the transition slice + +- Do not add handwritten or direct callers to the panic-based `CommandOptions.Fail` recorder. Do not add a new generator pattern that emits them. Existing generated commands temporarily inherit this adapter. Phase A changes the generator and removes generated `Fail` calls entirely. +- Do not resolve configuration or collect secrets a second time for display. Build one canonical resolved snapshot during normal option resolution. +- Do not read or mutate `color.NoColor` in new terminal code. Color must be command-local before concurrent execution is supported. +- Do not mutate a shared report while redacting it. Deep-copy controlled display data before sanitization. +- Do not add speculative fields. Remove fields that have no rendered or tested use. +- Do not make domain errors render their own reports. Keep family adapters at the terminal boundary. +- Do not couple families through generic report helpers or add a generic diagnosis framework. + +## How to add an error family + +A family is eligible only when it has a recurring user problem and stable typed evidence. + +1. **Name the owner and scope**: Identify the command family, the failure source, and any partial stdout behavior. +2. **Preserve the native chain**: Add operation context with `%w`; use a small domain error only for stable semantic facts. +3. **Define the fallback**: State the concise message and behavior when classification does not match. +4. **Add one terminal adapter**: Match typed errors or exported status codes before text. Copy only audited facts. +5. **Add an action only when safe**: Use typed tokens, long flags, canonical provenance, and no raw argv. +6. **Bound effects**: If the family diagnoses, specify its deadline, cancellation, external traffic, credentials, and kill switch or rollback. +7. **Prove compatibility**: Test `errors.Is` or `errors.As`, stderr and stdout, status, redaction, control characters, cancellation, and fallback. +8. **Pass the current phase gates**: Broader rollout is blocked until phases A through C pass. Project-standard status is blocked until phases D and E pass. + +## Safe Go examples + +Error text starts with lowercase and has no trailing punctuation. Wrap the cause with `%w`. + +```go +return fmt.Errorf("describe namespace %q: %w", namespace, err) +``` + +A domain error carries facts and a cause, not display layout. + +```go +type namespaceNotFoundError struct { + namespace string + cause error +} + +func (e *namespaceNotFoundError) Error() string { + return fmt.Sprintf("namespace not found: %s", e.namespace) +} + +func (e *namespaceNotFoundError) Unwrap() error { + return e.cause +} +``` + +The terminal adapter owns the user-facing summary. Keep it lowercase and free of punctuation. + +```go +func adaptNamespaceNotFound(err *namespaceNotFoundError) errorReport { + return errorReport{Summary: "namespace not found"} +} +``` + +Suggested commands use typed tokens and long flags. + +```go +displayInvocation{ + Command: []string{"temporal", "namespace", "describe"}, + Args: []string{"--namespace", namespace}, +} +``` + +## Security rules for displayed data + +Structured display data is a security boundary, not a copy of the error object. + +- Admit only fields that the family has classified and reviewed. +- Never replay raw argv. It may contain API keys, codec credentials, headers, or shell control characters. +- Build provenance from the canonical effective configuration. Record presence booleans separately for API key and OAuth. +- Never place API keys, authorization metadata, OAuth tokens, client secrets, certificate or key bytes, arbitrary URLs, or environment snapshots in a report or action. +- Reject NUL, newline, and every other control character before rendering any display field or invocation token. +- Quote each invocation token for the selected display shell. Producers supply unquoted tokens. +- Treat exact known-secret replacement as a final check, not a general scrubber. It cannot remove an unknown secret from legacy prose. +- Deep-copy the report before sanitization so rendering cannot alter domain state or another consumer's value. + +The current implementation rejects control characters in invocation tokens. It does not yet enforce the same rule across every summary, context value, check, action label, and usage field. That gap blocks project-standard status. + +## Diagnostics remain family-specific + +Diagnosis belongs beside the operation it examines. The terminal renderer must not run network, filesystem, or environment checks. + +For connection failures: + +- Start only after the real dial fails and while the command context remains active. +- Derive the diagnosis context from the command context, not the expired dial context. +- Use the earlier command deadline or a three-second family cap. +- Stop immediately on cancellation. +- Limit probes to documented DNS, TCP, and TLS activity against the configured target. +- Permit configured client certificates during TLS. Never send API-key or OAuth credentials or perform a Temporal RPC. +- Keep `TEMPORAL_CLI_DISABLE_CONNECT_DIAGNOSIS` as the rollback control. +- Describe probe effects because they may add DNS traffic, connections, TLS handshakes, and server log entries during an outage. + +The current model records only successful and failed stages. Phase E targets `unknown` and `skipped` observations. It also targets accurate typed deadline handling rather than describing current behavior. + +Do not create a generic diagnoser interface. Reconsider shared machinery only after three different families need materially different diagnosis behavior. + +## Output and exit contract + +| Outcome | stdout | stderr | status | +|---|---|---|---:| +| success or explicit help | successful data or help | empty unless a documented warning applies | `0` | +| parent-owned built-in failure | unchanged successful or documented partial data | one human report | `1` | +| started extension nonzero exit | child-owned | child-owned | child status | +| parent failure before extension start | empty unless already documented | one parent report | `1` | +| stderr write failure | unchanged | one attempted write | intended status | + +`PresentationErr` records a report write failure without replacing `CommandErr`. The binary currently ignores `PresentationErr` after `Execute` returns. Explicit ownership and observability therefore remain open transition work. + +The `-o json` and `-o jsonl` flags govern stdout data. They do not create a machine-readable stderr error contract. Parent-rendered errors remain human text. They must contain no ANSI when JSON output is requested. + +Usage belongs only to input failures for which the terminal can identify the affected command. Those failures render one error and one usage block on stderr. Pre-run, runtime, connection, and other operational failures do not add usage. + +## Current limitations block broad adoption + +- `Execute` is single-flight because command setup mutates `color.NoColor`. +- The panic recorder depends on every generated `Fail` call remaining terminal and synchronous. +- Connection display reconstructs provenance and known secrets instead of consuming one canonical resolved snapshot. +- Redaction mutates shallow report data and exact replacement does not make arbitrary fallback prose safe. +- Control-character rejection is complete for invocation tokens only. +- Authentication, authorization, and deadline diagnosis need stronger typed evidence and separate accuracy tests. +- Automatic connection probes can add pressure and server log noise during an outage. +- `PresentationErr` has no explicit process-level reporting or telemetry owner. +- Some report fields and helper couplings belong to the transition slice rather than the durable pattern. +- Direct `os.Exit` calls in successful-output broken-pipe handling remain outside this terminal-failure boundary. + +## Transition gates + +| Phase | Owner | Required result | Measurable exit criteria | +|---|---|---|---| +| A: isolate and retire recorder | command runtime and generator owners | Built-in failures return through normal control flow | No handwritten or direct `Fail` callers and no new generator pattern emits them; the changed generator removes generated `Fail` calls entirely; sentinel cleanup tests pass; panic recorder removed | +| B: canonical config and redaction snapshot | client config and security owners | One effective snapshot supplies provenance and known secrets | Flag, environment, profile, disabled-source, API-key, OAuth, header, and TLS precedence tests pass; duplicate resolution removed | +| C: command-local color and concurrency | command runtime owner | `Execute` no longer reads or writes global color state | Parallel conflicting-color `Execute` test passes under `go test -race`; single-flight restriction removed | +| D: terminal ownership and sanitization | terminal and security owners | Terminal adapters own conversion and sanitize deep copies | Family `report()` methods and unused fields removed; all display fields reject controls; mutation-isolation tests pass; `PresentationErr` owner documented | +| E: diagnosis correctness and operations | connection and resilience owners | Connection diagnosis is accurate and operationally bounded | Authn, authz, deadline, cancellation, wall-clock, no-credential, kill-switch, and outage-pressure tests pass; effects documented | +| F: third-family checkpoint | architecture owner and three family owners | Evidence decides whether extraction helps | Three genuinely different families are implemented; coupling is measured; keep explicit adapters unless a package or interface reduces proven duplication | + +Broader family rollout is blocked on A through C. Declaring this the project standard is blocked on D and E. A provider interface or package extraction is reconsidered only at F. + +## Validation slices and rollout + +PR [#1114](https://github.com/temporalio/cli/pull/1114) is the connection transition slice. Copy its staged user experience, bounded family diagnosis, cause preservation, and typed action direction. Do not copy its transition implementation as a general framework. + +Standalone Activity `NotFound` is the second validation slice. It proves that a typed server error can retain its `errors.As` chain. The terminal can also add conservative text without a speculative action. Two slices are not enough to justify an interface. + +Rollout order: + +1. Complete phases A through C before admitting more families. +2. Use connection and Activity tests to protect the two validation slices during the transition. +3. Complete phases D and E before publishing this pattern as the repository standard. +4. Admit one genuinely different third family with its own owner and compatibility matrix. +5. Run phase F and keep explicit adapters unless the three-family evidence supports extraction. + +## Where the transition lives + +### Core contracts + +| Contract | Location | Current role | +|---|---|---| +| `Result` and renderer | [`internal/temporalcli/terminal.go`](../internal/temporalcli/terminal.go) | Separates command, presentation, and status outcomes | +| command runtime | [`internal/temporalcli/commands.go`](../internal/temporalcli/commands.go) | Owns Cobra execution, usage, terminal handling, and transition recorder | +| process exit | [`cmd/temporal/main.go`](../cmd/temporal/main.go) | Converts `Result.ExitStatus` into process status | +| extension boundary | [`internal/temporalcli/commands.extension.go`](../internal/temporalcli/commands.extension.go) | Preserves child streams and child status | +| connection facts and actions | [`internal/temporalcli/connecterror.go`](../internal/temporalcli/connecterror.go) | Reference family adapter, with transition coupling still present | +| connection probes | [`internal/temporalcli/connectdiag.go`](../internal/temporalcli/connectdiag.go) | Three-second DNS, TCP, and TLS diagnosis | +| effective client setup | [`internal/temporalcli/client.go`](../internal/temporalcli/client.go) | Dial boundary and current duplicate display provenance resolution | + +### Test anchors + +- [`internal/temporalcli/terminal_test.go`](../internal/temporalcli/terminal_test.go) covers one-write behavior, error identity, shell quoting, redaction, color restoration, and recorder assumptions. +- [`internal/temporalcli/connectdiag_test.go`](../internal/temporalcli/connectdiag_test.go) covers transport classification, deadline interruption, actions, and rendered checks. +- [`internal/temporalcli/client_test.go`](../internal/temporalcli/client_test.go) covers dial timeouts, kill-switch behavior, JSON color, profile provenance, and command timeout. +- [`internal/temporalcli/commands.extension_test.go`](../internal/temporalcli/commands.extension_test.go) covers extension streams, status, timeout, and cancellation. +- [`internal/temporalcli/commands.activity_internal_test.go`](../internal/temporalcli/commands.activity_internal_test.go) covers Activity `NotFound` cause preservation. + +### Verification commands + +```sh +# This root command does not enter the nested cliext module. +go test ./... +# The current branch has a known standalone cliext compile mismatch. Until it is fixed, +# this command must match the recorded baseline exception and must not be called green. +(cd cliext && go test ./...) +# This currently exposes the documented global-color race and is expected to fail +# until Phase C. It becomes a required green gate in Phase C. +go test -race ./internal/temporalcli +make gen +git diff --exit-code +make gen-docs +``` + +Run focused transition tests while changing the terminal path: + +```sh +go test ./internal/temporalcli -run 'Test(HandleTerminalError|RenderErrorText|RecorderSeam|RenderInvocation|FinishCommand|KnownSecrets|CaptureEffective|DiagnoseConnection|ArmReadDeadline|ConnectDiagnosis_|Extension_|ActivityNotFound)' -count=1 +``` + +## Where to learn more + +- [`CONTRIBUTING.md`](../CONTRIBUTING.md) describes repository build, test, generation, and command contribution workflows. +- [PR #1114](https://github.com/temporalio/cli/pull/1114) is the reference connection transition slice. From e637b1bfad3d922fd84ec0586424b7ab4e18fcab Mon Sep 17 00:00:00 2001 From: Ross Nelson Date: Thu, 23 Jul 2026 16:15:56 -0400 Subject: [PATCH 6/9] Refactor CLI error handling --- CONTRIBUTING.md | 6 - docs/structured-error-handling.md | 304 ------ internal/commandsgen/code.go | 7 +- internal/commandsgen/code_test.go | 46 + internal/temporalcli/client.go | 106 +-- internal/temporalcli/client_test.go | 21 +- internal/temporalcli/commands.activity.go | 8 +- .../commands.activity_internal_test.go | 7 +- .../temporalcli/commands.activity_test.go | 2 +- .../temporalcli/commands.extension_test.go | 5 +- internal/temporalcli/commands.gen.go | 868 ++++++++---------- internal/temporalcli/commands.go | 27 +- internal/temporalcli/connectdiag.go | 118 ++- internal/temporalcli/connectdiag_test.go | 159 +++- internal/temporalcli/connecterror.go | 134 +-- internal/temporalcli/terminal.go | 168 +++- internal/temporalcli/terminal_test.go | 198 ++-- 17 files changed, 940 insertions(+), 1244 deletions(-) delete mode 100644 docs/structured-error-handling.md create mode 100644 internal/commandsgen/code_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8170c4ed7..ee7d75a77 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -24,17 +24,11 @@ Example to run a single test case: ## Adding/updating commands -Follow [Structured Error Handling](docs/structured-error-handling.md) when a command adds or changes terminal failure behavior. - First, update [commands.yaml](internal/temporalcli/commands.yaml) following the rules in that file. Then to regenerate the [commands.gen.go](internal/temporalcli/commands.gen.go) file from code, run: go run ./cmd/gen-commands -input internal/temporalcli/commands.yaml -pkg temporalcli -context "*CommandContext" > internal/temporalcli/commands.gen.go -Generated commands currently inherit the existing `cctx.Options.Fail(err)` adapter. Regeneration may retain those calls until Phase A of [Structured Error Handling](docs/structured-error-handling.md#transition-gates) changes the generator. - -Do not add handwritten or direct `Fail` calls. Do not create a new generator pattern that emits them or copy the adapter into new code. Phase A removes generated `Fail` calls entirely. - This will expect every non-parent command to have a `run` method, so for new commands developers will have to implement `run` on the new command in a separate file before it will compile. diff --git a/docs/structured-error-handling.md b/docs/structured-error-handling.md deleted file mode 100644 index 873e97cdf..000000000 --- a/docs/structured-error-handling.md +++ /dev/null @@ -1,304 +0,0 @@ -# Structured Error Handling - -PR [#1114](https://github.com/temporalio/cli/pull/1114) proves a useful error-handling direction, but transition work remains before it can become the repository standard. - -This guide defines the pattern that command families may adopt and the work needed to complete that transition. - -- **Last updated:** 2026-07-22 -- **Status:** transition architecture -- **Verdict:** PR [#1114](https://github.com/temporalio/cli/pull/1114) is a reference slice, not a finished general framework - -## What to adopt now - -Command and domain code should keep returning ordinary wrapped Go errors. The terminal boundary should convert those errors into safe display facts. It should also own one stderr render and the final status. - -Connection failures and Standalone Activity `NotFound` prove parts of this direction. However, the current branch still contains transition mechanisms that new code must not copy. - -- Adopt native wrapped errors, semantic domain facts, explicit terminal adapters, typed token actions, bounded diagnosis, and conservative fallback now. -- Do not copy the panic-based `Fail` recorder, duplicate config resolution, global color mutation, shallow redaction, family-owned report methods, or generic helper coupling. -- Do not add a provider interface yet. Keep explicit terminal adapters until three genuinely different families show a useful shared boundary. -- Do not roll the pattern across more command families until phases A through C in [Transition gates](#transition-gates) pass. - -Key files: - -- [`internal/temporalcli/terminal.go`](../internal/temporalcli/terminal.go) contains the transition report, renderer, recorder, and `Result`. -- [`internal/temporalcli/commands.go`](../internal/temporalcli/commands.go) owns command execution and final terminal handling. -- [`internal/temporalcli/connecterror.go`](../internal/temporalcli/connecterror.go) carries connection facts and actions in the reference slice. -- [`internal/temporalcli/connectdiag.go`](../internal/temporalcli/connectdiag.go) performs connection-specific diagnosis. -- [`internal/temporalcli/client.go`](../internal/temporalcli/client.go) resolves connection options and starts diagnosis after a failed dial. - -## Current flow is useful but transitional - -```mermaid -flowchart LR - A[Generated command callback] -->|calls Fail| B[Panic recorder] - B -->|native error| C[Terminal normalizer] - D[Failed Temporal dial] -->|DNS TCP TLS facts| E[Connection error] - E --> C - F[Activity polling] -->|typed not found error| C - C -->|mutable report| G[Exact-value redaction] - G -->|explicit render options| H[Text renderer] - H -->|one checked write| I[stderr] - C -->|Result and status| J[main] -``` - -The terminal boundary already returns a [`Result`](../internal/temporalcli/terminal.go). It separates `CommandErr` from `PresentationErr`. Started extensions keep their own output and child status. - -The renderer accepts explicit color and shell settings. However, command execution still mutates the package-level `color.NoColor` value. Concurrent `Execute` calls are therefore unsupported. - -Connection diagnosis is family-specific and runs only after a failure. It starts after the real dial fails and uses the command context. A three-second cap bounds its work. Set `TEMPORAL_CLI_DISABLE_CONNECT_DIAGNOSIS` to disable it. - -## How terminal failures flow - -### Reporting a built-in command failure - -**Trigger:** A built-in command reaches its final error. - -1. **Preserve the cause**: Command code returns the error or adds lowercase operation context with `%w`. -2. **Carry semantic facts**: A domain error stores stable facts that callers may inspect. It does not store color, indentation, or command text. -3. **Adapt at the terminal**: An explicit family adapter converts supported facts into controlled display fields. -4. **Render once**: The current terminal boundary exact-redacts registered secret values and renders one byte slice. It then attempts one checked stderr write. Phase D adds deep-copy and control sanitization for every display field. -5. **Return ownership**: `Result.CommandErr` keeps the error chain, `Result.PresentationErr` keeps a write failure, and `ExitStatus` carries the status. - -**When no adapter matches:** Preserve concise legacy text and omit context, checks, and actions that have not passed a family security review. - -### Diagnosing a failed connection - -**Trigger:** The real Temporal client dial fails while the command context is still active. - -1. **Check eligibility**: Skip diagnosis after cancellation or when the kill switch is set. -2. **Apply a family budget**: Bound the connection probe by the command deadline and a three-second maximum. -3. **Collect observations**: Run DNS, TCP, and TLS checks against the configured target. -4. **Avoid privileged retries**: Do not send an API key, OAuth credential, or Temporal RPC. A TLS handshake may present configured client certificates. -5. **Return facts**: Preserve the original dial error and attach only supported observations. - -**When diagnosis is unavailable:** Current behavior preserves the original error. The current model has no `unknown` or `skipped` observation state. Deadline classification also remains a known accuracy gap. Phase E adds those states and the related accuracy tests. - -A later probe must never be presented as proof of the original failure. - -### Passing through a started extension failure - -**Trigger:** A `temporal-*` extension starts and exits nonzero. - -1. **Keep child streams**: The extension owns its stdout and stderr. -2. **Return the child status**: The parent returns `ExtensionNonZeroExit` with the child exit code. -3. **Skip parent rendering**: The parent does not add another error report. - -**When the child never starts:** The parent owns the failure and uses the normal terminal path. - -## Ownership boundary for new code - -### Copy and adopt now - -- Return native Go errors through the call graph. -- Add context with `%w` so `errors.Is` and `errors.As` keep working. -- Put stable semantic facts and the wrapped cause in a small domain error when callers need typed behavior. -- Add an explicit terminal-owned adapter for each admitted family. -- Render actions from typed command and argument tokens. Use long flags in displayed commands. -- Keep diagnosis beside the failing operation, with a family budget, cancellation, documented effects, and a rollback control. -- Preserve a conservative unknown fallback. -- Write parent-owned terminal output once to stderr and return status explicitly. - -### Do not copy from the transition slice - -- Do not add handwritten or direct callers to the panic-based `CommandOptions.Fail` recorder. Do not add a new generator pattern that emits them. Existing generated commands temporarily inherit this adapter. Phase A changes the generator and removes generated `Fail` calls entirely. -- Do not resolve configuration or collect secrets a second time for display. Build one canonical resolved snapshot during normal option resolution. -- Do not read or mutate `color.NoColor` in new terminal code. Color must be command-local before concurrent execution is supported. -- Do not mutate a shared report while redacting it. Deep-copy controlled display data before sanitization. -- Do not add speculative fields. Remove fields that have no rendered or tested use. -- Do not make domain errors render their own reports. Keep family adapters at the terminal boundary. -- Do not couple families through generic report helpers or add a generic diagnosis framework. - -## How to add an error family - -A family is eligible only when it has a recurring user problem and stable typed evidence. - -1. **Name the owner and scope**: Identify the command family, the failure source, and any partial stdout behavior. -2. **Preserve the native chain**: Add operation context with `%w`; use a small domain error only for stable semantic facts. -3. **Define the fallback**: State the concise message and behavior when classification does not match. -4. **Add one terminal adapter**: Match typed errors or exported status codes before text. Copy only audited facts. -5. **Add an action only when safe**: Use typed tokens, long flags, canonical provenance, and no raw argv. -6. **Bound effects**: If the family diagnoses, specify its deadline, cancellation, external traffic, credentials, and kill switch or rollback. -7. **Prove compatibility**: Test `errors.Is` or `errors.As`, stderr and stdout, status, redaction, control characters, cancellation, and fallback. -8. **Pass the current phase gates**: Broader rollout is blocked until phases A through C pass. Project-standard status is blocked until phases D and E pass. - -## Safe Go examples - -Error text starts with lowercase and has no trailing punctuation. Wrap the cause with `%w`. - -```go -return fmt.Errorf("describe namespace %q: %w", namespace, err) -``` - -A domain error carries facts and a cause, not display layout. - -```go -type namespaceNotFoundError struct { - namespace string - cause error -} - -func (e *namespaceNotFoundError) Error() string { - return fmt.Sprintf("namespace not found: %s", e.namespace) -} - -func (e *namespaceNotFoundError) Unwrap() error { - return e.cause -} -``` - -The terminal adapter owns the user-facing summary. Keep it lowercase and free of punctuation. - -```go -func adaptNamespaceNotFound(err *namespaceNotFoundError) errorReport { - return errorReport{Summary: "namespace not found"} -} -``` - -Suggested commands use typed tokens and long flags. - -```go -displayInvocation{ - Command: []string{"temporal", "namespace", "describe"}, - Args: []string{"--namespace", namespace}, -} -``` - -## Security rules for displayed data - -Structured display data is a security boundary, not a copy of the error object. - -- Admit only fields that the family has classified and reviewed. -- Never replay raw argv. It may contain API keys, codec credentials, headers, or shell control characters. -- Build provenance from the canonical effective configuration. Record presence booleans separately for API key and OAuth. -- Never place API keys, authorization metadata, OAuth tokens, client secrets, certificate or key bytes, arbitrary URLs, or environment snapshots in a report or action. -- Reject NUL, newline, and every other control character before rendering any display field or invocation token. -- Quote each invocation token for the selected display shell. Producers supply unquoted tokens. -- Treat exact known-secret replacement as a final check, not a general scrubber. It cannot remove an unknown secret from legacy prose. -- Deep-copy the report before sanitization so rendering cannot alter domain state or another consumer's value. - -The current implementation rejects control characters in invocation tokens. It does not yet enforce the same rule across every summary, context value, check, action label, and usage field. That gap blocks project-standard status. - -## Diagnostics remain family-specific - -Diagnosis belongs beside the operation it examines. The terminal renderer must not run network, filesystem, or environment checks. - -For connection failures: - -- Start only after the real dial fails and while the command context remains active. -- Derive the diagnosis context from the command context, not the expired dial context. -- Use the earlier command deadline or a three-second family cap. -- Stop immediately on cancellation. -- Limit probes to documented DNS, TCP, and TLS activity against the configured target. -- Permit configured client certificates during TLS. Never send API-key or OAuth credentials or perform a Temporal RPC. -- Keep `TEMPORAL_CLI_DISABLE_CONNECT_DIAGNOSIS` as the rollback control. -- Describe probe effects because they may add DNS traffic, connections, TLS handshakes, and server log entries during an outage. - -The current model records only successful and failed stages. Phase E targets `unknown` and `skipped` observations. It also targets accurate typed deadline handling rather than describing current behavior. - -Do not create a generic diagnoser interface. Reconsider shared machinery only after three different families need materially different diagnosis behavior. - -## Output and exit contract - -| Outcome | stdout | stderr | status | -|---|---|---|---:| -| success or explicit help | successful data or help | empty unless a documented warning applies | `0` | -| parent-owned built-in failure | unchanged successful or documented partial data | one human report | `1` | -| started extension nonzero exit | child-owned | child-owned | child status | -| parent failure before extension start | empty unless already documented | one parent report | `1` | -| stderr write failure | unchanged | one attempted write | intended status | - -`PresentationErr` records a report write failure without replacing `CommandErr`. The binary currently ignores `PresentationErr` after `Execute` returns. Explicit ownership and observability therefore remain open transition work. - -The `-o json` and `-o jsonl` flags govern stdout data. They do not create a machine-readable stderr error contract. Parent-rendered errors remain human text. They must contain no ANSI when JSON output is requested. - -Usage belongs only to input failures for which the terminal can identify the affected command. Those failures render one error and one usage block on stderr. Pre-run, runtime, connection, and other operational failures do not add usage. - -## Current limitations block broad adoption - -- `Execute` is single-flight because command setup mutates `color.NoColor`. -- The panic recorder depends on every generated `Fail` call remaining terminal and synchronous. -- Connection display reconstructs provenance and known secrets instead of consuming one canonical resolved snapshot. -- Redaction mutates shallow report data and exact replacement does not make arbitrary fallback prose safe. -- Control-character rejection is complete for invocation tokens only. -- Authentication, authorization, and deadline diagnosis need stronger typed evidence and separate accuracy tests. -- Automatic connection probes can add pressure and server log noise during an outage. -- `PresentationErr` has no explicit process-level reporting or telemetry owner. -- Some report fields and helper couplings belong to the transition slice rather than the durable pattern. -- Direct `os.Exit` calls in successful-output broken-pipe handling remain outside this terminal-failure boundary. - -## Transition gates - -| Phase | Owner | Required result | Measurable exit criteria | -|---|---|---|---| -| A: isolate and retire recorder | command runtime and generator owners | Built-in failures return through normal control flow | No handwritten or direct `Fail` callers and no new generator pattern emits them; the changed generator removes generated `Fail` calls entirely; sentinel cleanup tests pass; panic recorder removed | -| B: canonical config and redaction snapshot | client config and security owners | One effective snapshot supplies provenance and known secrets | Flag, environment, profile, disabled-source, API-key, OAuth, header, and TLS precedence tests pass; duplicate resolution removed | -| C: command-local color and concurrency | command runtime owner | `Execute` no longer reads or writes global color state | Parallel conflicting-color `Execute` test passes under `go test -race`; single-flight restriction removed | -| D: terminal ownership and sanitization | terminal and security owners | Terminal adapters own conversion and sanitize deep copies | Family `report()` methods and unused fields removed; all display fields reject controls; mutation-isolation tests pass; `PresentationErr` owner documented | -| E: diagnosis correctness and operations | connection and resilience owners | Connection diagnosis is accurate and operationally bounded | Authn, authz, deadline, cancellation, wall-clock, no-credential, kill-switch, and outage-pressure tests pass; effects documented | -| F: third-family checkpoint | architecture owner and three family owners | Evidence decides whether extraction helps | Three genuinely different families are implemented; coupling is measured; keep explicit adapters unless a package or interface reduces proven duplication | - -Broader family rollout is blocked on A through C. Declaring this the project standard is blocked on D and E. A provider interface or package extraction is reconsidered only at F. - -## Validation slices and rollout - -PR [#1114](https://github.com/temporalio/cli/pull/1114) is the connection transition slice. Copy its staged user experience, bounded family diagnosis, cause preservation, and typed action direction. Do not copy its transition implementation as a general framework. - -Standalone Activity `NotFound` is the second validation slice. It proves that a typed server error can retain its `errors.As` chain. The terminal can also add conservative text without a speculative action. Two slices are not enough to justify an interface. - -Rollout order: - -1. Complete phases A through C before admitting more families. -2. Use connection and Activity tests to protect the two validation slices during the transition. -3. Complete phases D and E before publishing this pattern as the repository standard. -4. Admit one genuinely different third family with its own owner and compatibility matrix. -5. Run phase F and keep explicit adapters unless the three-family evidence supports extraction. - -## Where the transition lives - -### Core contracts - -| Contract | Location | Current role | -|---|---|---| -| `Result` and renderer | [`internal/temporalcli/terminal.go`](../internal/temporalcli/terminal.go) | Separates command, presentation, and status outcomes | -| command runtime | [`internal/temporalcli/commands.go`](../internal/temporalcli/commands.go) | Owns Cobra execution, usage, terminal handling, and transition recorder | -| process exit | [`cmd/temporal/main.go`](../cmd/temporal/main.go) | Converts `Result.ExitStatus` into process status | -| extension boundary | [`internal/temporalcli/commands.extension.go`](../internal/temporalcli/commands.extension.go) | Preserves child streams and child status | -| connection facts and actions | [`internal/temporalcli/connecterror.go`](../internal/temporalcli/connecterror.go) | Reference family adapter, with transition coupling still present | -| connection probes | [`internal/temporalcli/connectdiag.go`](../internal/temporalcli/connectdiag.go) | Three-second DNS, TCP, and TLS diagnosis | -| effective client setup | [`internal/temporalcli/client.go`](../internal/temporalcli/client.go) | Dial boundary and current duplicate display provenance resolution | - -### Test anchors - -- [`internal/temporalcli/terminal_test.go`](../internal/temporalcli/terminal_test.go) covers one-write behavior, error identity, shell quoting, redaction, color restoration, and recorder assumptions. -- [`internal/temporalcli/connectdiag_test.go`](../internal/temporalcli/connectdiag_test.go) covers transport classification, deadline interruption, actions, and rendered checks. -- [`internal/temporalcli/client_test.go`](../internal/temporalcli/client_test.go) covers dial timeouts, kill-switch behavior, JSON color, profile provenance, and command timeout. -- [`internal/temporalcli/commands.extension_test.go`](../internal/temporalcli/commands.extension_test.go) covers extension streams, status, timeout, and cancellation. -- [`internal/temporalcli/commands.activity_internal_test.go`](../internal/temporalcli/commands.activity_internal_test.go) covers Activity `NotFound` cause preservation. - -### Verification commands - -```sh -# This root command does not enter the nested cliext module. -go test ./... -# The current branch has a known standalone cliext compile mismatch. Until it is fixed, -# this command must match the recorded baseline exception and must not be called green. -(cd cliext && go test ./...) -# This currently exposes the documented global-color race and is expected to fail -# until Phase C. It becomes a required green gate in Phase C. -go test -race ./internal/temporalcli -make gen -git diff --exit-code -make gen-docs -``` - -Run focused transition tests while changing the terminal path: - -```sh -go test ./internal/temporalcli -run 'Test(HandleTerminalError|RenderErrorText|RecorderSeam|RenderInvocation|FinishCommand|KnownSecrets|CaptureEffective|DiagnoseConnection|ArmReadDeadline|ConnectDiagnosis_|Extension_|ActivityNotFound)' -count=1 -``` - -## Where to learn more - -- [`CONTRIBUTING.md`](../CONTRIBUTING.md) describes repository build, test, generation, and command contribution workflows. -- [PR #1114](https://github.com/temporalio/cli/pull/1114) is the reference connection transition slice. diff --git a/internal/commandsgen/code.go b/internal/commandsgen/code.go index 84cff3bbc..5dee37a0c 100644 --- a/internal/commandsgen/code.go +++ b/internal/commandsgen/code.go @@ -302,10 +302,9 @@ func (c *Command) writeCode(w *codeWriter) error { } // If there are no subcommands, or if subcommands are optional, we need a run function if len(subCommands) == 0 || c.SubcommandsOptional { - w.writeLinef("s.Command.Run = func(c *%v.Command, args []string) {", w.importCobra()) - w.writeLinef("if err := s.run(cctx, args); err != nil {") - w.writeLinef("cctx.Options.Fail(err)") - w.writeLinef("}") + w.writeLinef("s.Command.RunE = func(c *%v.Command, args []string) error {", w.importCobra()) + w.writeLinef("cctx.commandRunStarted = true") + w.writeLinef("return s.run(cctx, args)") w.writeLinef("}") } // Init diff --git a/internal/commandsgen/code_test.go b/internal/commandsgen/code_test.go new file mode 100644 index 000000000..050d922d1 --- /dev/null +++ b/internal/commandsgen/code_test.go @@ -0,0 +1,46 @@ +package commandsgen + +import ( + "bytes" + "testing" +) + +func TestGenerateRunnableCommandReturnsRunError(t *testing.T) { + generated, err := GenerateCommandsCode("temporalcli", "*CommandContext", Commands{ + CommandList: []Command{ + { + FullName: "temporal", + NamePath: []string{"temporal"}, + Summary: "Temporal commands", + DescriptionPlain: "Temporal commands", + }, + { + FullName: "temporal example", + NamePath: []string{"temporal", "example"}, + Summary: "Example command", + DescriptionPlain: "Example command", + }, + }, + }) + if err != nil { + t.Fatalf("GenerateCommandsCode() error = %v", err) + } + + for _, want := range [][]byte{ + []byte("s.Command.RunE = func(c *cobra.Command, args []string) error"), + []byte("cctx.commandRunStarted = true"), + []byte("return s.run(cctx, args)"), + } { + if !bytes.Contains(generated, want) { + t.Errorf("generated code does not contain %q", want) + } + } + for _, unwanted := range [][]byte{ + []byte("Options.Fail"), + []byte("s.Command.Run = func"), + } { + if bytes.Contains(generated, unwanted) { + t.Errorf("generated code unexpectedly contains %q", unwanted) + } + } +} diff --git a/internal/temporalcli/client.go b/internal/temporalcli/client.go index d96a41685..e2786770c 100644 --- a/internal/temporalcli/client.go +++ b/internal/temporalcli/client.go @@ -7,12 +7,10 @@ import ( "io/fs" "os" "os/user" - "strings" "github.com/temporalio/cli/cliext" "go.temporal.io/api/common/v1" "go.temporal.io/sdk/client" - "go.temporal.io/sdk/contrib/envconfig" "go.temporal.io/sdk/converter" "go.temporal.io/sdk/workflow" "google.golang.org/grpc" @@ -50,8 +48,6 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client. } c.Identity = "temporal-cli:" + username + "@" + hostname } - captureEffectiveConnectionSecrets(cctx, c) - // Build client options using cliext builder := &cliext.ClientOptionsBuilder{ CommonOptions: cctx.RootCommand.CommonOptions, @@ -61,16 +57,14 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client. } clientOpts, err := builder.Build(cctx) if err != nil { - // An unreadable TLS/credential file is a connection-setup problem - // worth a suggestion, even though no dial happened. + // Build did not return authoritative effective options. Preserve the + // original setup error instead of attaching a guessed address or profile. var pathErr *fs.PathError if errors.As(err, &pathErr) { - diag := &connectDiagnosis{ - Address: c.Address, - Cause: causeCertFileUnreadable, - Detail: pathErr.Path, - } - return nil, nil, newConnectError(diag, connectMetaFromOptions(cctx, c, client.Options{HostPort: c.Address}), err) + return nil, nil, newConnectError(&connectDiagnosis{ + Cause: causeCertFileUnreadable, + Detail: pathErr.Path, + }, connectMeta{}, err) } return nil, nil, err } @@ -103,7 +97,7 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client. cl, err := client.DialContext(dialCtx, clientOpts) if err != nil { - return nil, nil, dialConnectError(cctx, dialCtx, c, clientOpts, err) + return nil, nil, dialConnectError(cctx, dialCtx, clientOpts, err) } // Since this namespace value is used by many commands after this call, @@ -118,7 +112,6 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client. func dialConnectError( cctx *CommandContext, dialCtx context.Context, - configured *cliext.ClientOptions, clientOpts client.Options, origErr error, ) error { @@ -140,87 +133,14 @@ func dialConnectError( // Diagnosis uses the remaining command context and applies its own 3s cap. // ClientConnectTimeout governs only the original dial. diag := diagnoseConnection(cctx, clientOpts.HostPort, clientOpts.ConnectionOptions.TLS, origErr) - meta := connectMetaFromOptions(cctx, configured, clientOpts) - meta.TLSConfigured = clientOpts.ConnectionOptions.TLS != nil - return newConnectError(diag, meta, origErr) -} - -func connectMetaFromOptions(cctx *CommandContext, configured *cliext.ClientOptions, resolved client.Options) connectMeta { - meta := connectMeta{Address: resolved.HostPort, HasAPIKey: cctx.hasAPIKey, HasOAuth: cctx.hasOAuth} - if configured != nil && configured.FlagSet != nil && configured.FlagSet.Changed("address") { - meta.AddressSource = "flag" - } else if configured != nil && resolved.HostPort != configured.Address { - meta.AddressSource = "profile" - } else { - meta.AddressSource = "default" - } - if cctx.RootCommand != nil { - meta.ProfileName = cctx.RootCommand.CommonOptions.Profile - } - return meta + return newConnectError(diag, connectMetaFromOptions(clientOpts), origErr) } -func captureEffectiveConnectionSecrets(cctx *CommandContext, configured *cliext.ClientOptions) { - if configured != nil { - cctx.registerSecrets( - configured.ApiKey, - configured.CodecAuth, - configured.TlsCertData, - configured.TlsKeyData, - configured.TlsCaData, - ) - for _, value := range append(append([]string(nil), configured.CodecHeader...), configured.GrpcMeta...) { - cctx.registerSecrets(value) - if _, secret, ok := strings.Cut(value, "="); ok { - cctx.registerSecrets(secret) - } - } - if configured.ApiKey != "" { - cctx.hasAPIKey = true - } - } - if cctx.RootCommand == nil { - return - } - common := cctx.RootCommand.CommonOptions - if !common.DisableConfigFile || !common.DisableConfigEnv { - profile, err := envconfig.LoadClientConfigProfile(envconfig.LoadClientConfigProfileOptions{ - ConfigFilePath: common.ConfigFile, ConfigFileProfile: common.Profile, - DisableFile: common.DisableConfigFile, DisableEnv: common.DisableConfigEnv, - EnvLookup: cctx.Options.EnvLookup, - }) - if err == nil { - cctx.registerSecrets(profile.APIKey) - cctx.hasAPIKey = cctx.hasAPIKey || profile.APIKey != "" - if profile.TLS != nil { - cctx.registerSecrets(string(profile.TLS.ClientCertData), string(profile.TLS.ClientKeyData), string(profile.TLS.ServerCACertData)) - } - if profile.Codec != nil { - cctx.registerSecrets(profile.Codec.Auth) - } - for _, value := range profile.GRPCMeta { - cctx.registerSecrets(value) - } - } - } - // Match ClientOptionsBuilder precedence: an explicit --api-key suppresses - // OAuth loading, while a profile API key can still be replaced by a usable - // OAuth token from the same profile. - if common.DisableConfigFile || configured != nil && configured.ApiKey != "" { - return - } - oauth, err := cliext.LoadClientOAuth(cliext.LoadClientOAuthOptions{ - ConfigFilePath: common.ConfigFile, ProfileName: common.Profile, EnvLookup: cctx.Options.EnvLookup, - }) - if err != nil || oauth.OAuth == nil { - return - } - if oauth.OAuth.ClientConfig != nil { - cctx.registerSecrets(oauth.OAuth.ClientConfig.ClientSecret) - } - if oauth.OAuth.Token != nil { - cctx.registerSecrets(oauth.OAuth.Token.AccessToken, oauth.OAuth.Token.RefreshToken) - cctx.hasOAuth = oauth.OAuth.Token.AccessToken != "" +func connectMetaFromOptions(resolved client.Options) connectMeta { + return connectMeta{ + Address: resolved.HostPort, + Namespace: resolved.Namespace, + TLSConfigured: resolved.ConnectionOptions.TLS != nil, } } diff --git a/internal/temporalcli/client_test.go b/internal/temporalcli/client_test.go index c83039313..ae61176a1 100644 --- a/internal/temporalcli/client_test.go +++ b/internal/temporalcli/client_test.go @@ -125,11 +125,11 @@ func TestConnectDiagnosis_MTLSRequired(t *testing.T) { require.Error(t, res.Err) msg := res.Stderr.String() - assert.Contains(t, msg, "Connecting to "+addr) + assert.Contains(t, msg, "Connection checks for "+addr) assert.Contains(t, msg, "✓ TCP connection established") assert.Contains(t, msg, "✗ TLS handshake failed: server requires mTLS") - assert.Contains(t, msg, "tls.client_cert_path --value YourCert.pem") - assert.Contains(t, msg, "tls.client_key_path --value YourKey.pem") + assert.Contains(t, msg, "--tls-cert-path") + assert.Contains(t, msg, "--tls-key-path") } func TestConnectDiagnosis_Refused(t *testing.T) { @@ -206,6 +206,9 @@ func TestConnectDiagnosis_Disabled(t *testing.T) { msg := res.Stderr.String() assert.Contains(t, msg, "failed connecting to Temporal server at "+addr) assert.NotContains(t, msg, "✗", "diagnosis must be suppressed when disabled") + assert.NotContains(t, msg, "Namespace:") + assert.NotContains(t, msg, "TLS:") + assert.NotContains(t, msg, "Connection checks") } func TestConnectDiagnosis_CertFileMissing(t *testing.T) { @@ -218,15 +221,17 @@ func TestConnectDiagnosis_CertFileMissing(t *testing.T) { ) require.Error(t, res.Err) + var pathErr *os.PathError + require.ErrorAs(t, res.Err, &pathErr) msg := res.Stderr.String() + assert.Contains(t, msg, "failed preparing Temporal server connection") + assert.NotContains(t, msg, "server connection at", "Build returned no authoritative effective address") assert.Contains(t, msg, "cannot read file") assert.Contains(t, msg, "/definitely/does/not/exist") assert.NotContains(t, msg, "✓", "no probe stages expected when the dial never happened") } -func TestConnectDiagnosis_ProfileAddressNamed(t *testing.T) { - // When the failing address comes from a config profile, the suggestion - // should say so and point at `temporal config get`. +func TestConnectDiagnosis_ProfileAddressUsesEffectiveAddressWithoutGuessingProvenance(t *testing.T) { f, err := os.CreateTemp("", "") require.NoError(t, err) t.Cleanup(func() { os.Remove(f.Name()) }) @@ -247,8 +252,8 @@ address = "does-not-exist.invalid:7233" require.Error(t, res.Err) msg := res.Stderr.String() assert.Contains(t, msg, "failed connecting to Temporal server at does-not-exist.invalid:7233") - assert.Contains(t, msg, `The address comes from config profile "default"`) - assert.Contains(t, msg, "temporal config get --prop address --profile default") + assert.NotContains(t, msg, "config profile") + assert.NotContains(t, msg, "temporal config get") } func TestConnectDiagnosis_CommandTimeoutCauseSurvives(t *testing.T) { diff --git a/internal/temporalcli/commands.activity.go b/internal/temporalcli/commands.activity.go index ba7ecc90d..2b860fa15 100644 --- a/internal/temporalcli/commands.activity.go +++ b/internal/temporalcli/commands.activity.go @@ -241,8 +241,8 @@ func getActivityResult(cctx *CommandContext, cl client.Client, namespace, activi } } -// activityNotFoundError retains the server's typed NotFound error while -// carrying the audited display semantics for this command family. +// activityNotFoundError retains the activity identity and the server's typed +// NotFound error. Terminal presentation is owned by terminal.go. type activityNotFoundError struct { activityID string cause error @@ -256,10 +256,6 @@ func (e *activityNotFoundError) Unwrap() error { return e.cause } -func (e *activityNotFoundError) report() errorReport { - return errorReport{Summary: "standalone Activity not found"} -} - // Matches the SDK's pollActivityTimeout in internal_activity_client.go. const pollActivityTimeout = 60 * time.Second diff --git a/internal/temporalcli/commands.activity_internal_test.go b/internal/temporalcli/commands.activity_internal_test.go index 43829453d..861cb6851 100644 --- a/internal/temporalcli/commands.activity_internal_test.go +++ b/internal/temporalcli/commands.activity_internal_test.go @@ -10,7 +10,7 @@ import ( "go.temporal.io/api/serviceerror" ) -func TestActivityNotFoundErrorPreservesCauseAndHasConservativeReport(t *testing.T) { +func TestActivityNotFoundErrorPreservesIdentityAndCauseWithoutPresentationState(t *testing.T) { cause, ok := serviceerror.NewNotFound("server detail").(*serviceerror.NotFound) require.True(t, ok) wrappedCause := fmt.Errorf("poll failed: %w", cause) @@ -21,11 +21,6 @@ func TestActivityNotFoundErrorPreservesCauseAndHasConservativeReport(t *testing. assert.Same(t, cause, notFound) assert.Equal(t, "activity not found: activity-id", err.Error()) - report := err.report() - assert.Equal(t, "standalone Activity not found", report.Summary) - assert.Empty(t, report.Context) - assert.Empty(t, report.Checks) - assert.Nil(t, report.Action) assert.True(t, errors.Is(err, wrappedCause)) assert.True(t, errors.Is(err, cause)) } diff --git a/internal/temporalcli/commands.activity_test.go b/internal/temporalcli/commands.activity_test.go index 1bdfd4e55..e53ce6472 100644 --- a/internal/temporalcli/commands.activity_test.go +++ b/internal/temporalcli/commands.activity_test.go @@ -822,7 +822,7 @@ func (s *SharedServerSuite) TestActivity_Result_NotFound() { var notFound *serviceerror.NotFound s.ErrorAs(res.Err, ¬Found) s.Equal(1, res.Runtime.ExitStatus) - s.Contains(res.Stderr.String(), "Error: standalone Activity not found") + s.Equal("Error: standalone Activity not found\n", res.Stderr.String()) s.NotContains(res.Stderr.String(), "Try") s.Empty(res.Stdout.String()) } diff --git a/internal/temporalcli/commands.extension_test.go b/internal/temporalcli/commands.extension_test.go index 2d438f146..810215203 100644 --- a/internal/temporalcli/commands.extension_test.go +++ b/internal/temporalcli/commands.extension_test.go @@ -245,7 +245,7 @@ func TestExtension_FailsOnNonExecutableCommand(t *testing.T) { func TestExtension_PassesThroughNonZeroExit(t *testing.T) { h := newExtensionHarness(t) - h.createExtension("temporal-foo", codeEchoArgs, codeExit(42)) + h.createExtension("temporal-foo", codeEchoArgs, codeEchoStderr("child error"), codeExit(42)) res := h.Execute("foo") @@ -253,7 +253,8 @@ func TestExtension_PassesThroughNonZeroExit(t *testing.T) { var extensionErr temporalcli.ExtensionNonZeroExit assert.ErrorAs(t, res.Err, &extensionErr) assert.Equal(t, 42, res.Runtime.ExitStatus) - assert.Empty(t, res.Stderr.String(), "extension owns its stderr") + assert.Equal(t, "child error\n", res.Stderr.String(), "extension owns its stderr without a parent report") + assert.NotContains(t, res.Stderr.String(), "Error:") } func TestExtension_FailsOnCommandTimeout(t *testing.T) { diff --git a/internal/temporalcli/commands.gen.go b/internal/temporalcli/commands.gen.go index ce21f9d26..bb91fc3b5 100644 --- a/internal/temporalcli/commands.gen.go +++ b/internal/temporalcli/commands.gen.go @@ -577,10 +577,9 @@ func NewTemporalActivityCancelCommand(cctx *CommandContext, parent *TemporalActi s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for cancellation.") s.ActivityReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -612,10 +611,9 @@ func NewTemporalActivityCompleteCommand(cctx *CommandContext, parent *TemporalAc s.Command.Flags().StringVarP(&s.RunId, "run-id", "r", "", "Run ID. For workflow Activities (when --workflow-id is provided), this is the Workflow Run ID. For Standalone Activities, this is the Activity Run ID.") s.Command.Flags().StringVar(&s.Result, "result", "", "Result `JSON` to return. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "result") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -639,10 +637,9 @@ func NewTemporalActivityCountCommand(cctx *CommandContext, parent *TemporalActiv } s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Query to filter Activity Executions to count.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -668,10 +665,9 @@ func NewTemporalActivityDescribeCommand(cctx *CommandContext, parent *TemporalAc s.Command.Args = cobra.NoArgs s.Command.Flags().BoolVar(&s.Raw, "raw", false, "Print properties without changing their format.") s.ActivityReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -697,10 +693,9 @@ func NewTemporalActivityExecuteCommand(cctx *CommandContext, parent *TemporalAct s.Command.Args = cobra.NoArgs s.ActivityStartOptions.BuildFlags(s.Command.Flags()) s.PayloadInputOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -733,10 +728,9 @@ func NewTemporalActivityFailCommand(cctx *CommandContext, parent *TemporalActivi s.Command.Flags().StringVarP(&s.RunId, "run-id", "r", "", "Run ID. For workflow Activities (when --workflow-id is provided), this is the Workflow Run ID. For Standalone Activities, this is the Activity Run ID.") s.Command.Flags().StringVar(&s.Detail, "detail", "", "Failure detail (JSON). Attached as the failure details payload.") s.Command.Flags().StringVar(&s.Reason, "reason", "", "Failure reason. Attached as the failure message.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -764,10 +758,9 @@ func NewTemporalActivityListCommand(cctx *CommandContext, parent *TemporalActivi s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Query to filter the Activity Executions to list.") s.Command.Flags().IntVar(&s.Limit, "limit", 0, "Maximum number of Activity Executions to display.") s.Command.Flags().IntVar(&s.PageSize, "page-size", 0, "Maximum number of Activity Executions to fetch at a time from the server.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -797,10 +790,9 @@ func NewTemporalActivityPauseCommand(cctx *CommandContext, parent *TemporalActiv s.Command.Flags().StringVar(&s.Identity, "identity", "", "The identity of the user or client submitting this request.") s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for pausing the Activity.") s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -837,10 +829,9 @@ func NewTemporalActivityResetCommand(cctx *CommandContext, parent *TemporalActiv s.Command.Flags().Var(&s.Jitter, "jitter", "The activity will reset at random a time within the specified duration. Can only be used with --query.") s.Command.Flags().BoolVar(&s.RestoreOriginalOptions, "restore-original-options", false, "Restore the original options of the activity.") s.SingleActivityOrBatchOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -864,10 +855,9 @@ func NewTemporalActivityResultCommand(cctx *CommandContext, parent *TemporalActi } s.Command.Args = cobra.NoArgs s.ActivityReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -893,10 +883,9 @@ func NewTemporalActivityStartCommand(cctx *CommandContext, parent *TemporalActiv s.Command.Args = cobra.NoArgs s.ActivityStartOptions.BuildFlags(s.Command.Flags()) s.PayloadInputOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -922,10 +911,9 @@ func NewTemporalActivityTerminateCommand(cctx *CommandContext, parent *TemporalA s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for termination. Defaults to a message with the current user's name.") s.ActivityReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -958,10 +946,9 @@ func NewTemporalActivityUnpauseCommand(cctx *CommandContext, parent *TemporalAct s.Jitter = 0 s.Command.Flags().Var(&s.Jitter, "jitter", "The activity will start at random a time within the specified duration. Can only be used with --query.") s.SingleActivityOrBatchOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1013,10 +1000,9 @@ func NewTemporalActivityUpdateOptionsCommand(cctx *CommandContext, parent *Tempo s.Command.Flags().IntVar(&s.RetryMaximumAttempts, "retry-maximum-attempts", 0, "Maximum number of attempts. When exceeded the retries stop even if not expired yet. Setting this value to 1 disables retries. Setting this value to 0 means unlimited attempts(up to the timeouts).") s.Command.Flags().BoolVar(&s.RestoreOriginalOptions, "restore-original-options", false, "Restore the original options of the activity.") s.SingleActivityOrBatchOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1065,10 +1051,9 @@ func NewTemporalBatchDescribeCommand(cctx *CommandContext, parent *TemporalBatch s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.JobId, "job-id", "", "Batch job ID. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "job-id") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1092,10 +1077,9 @@ func NewTemporalBatchListCommand(cctx *CommandContext, parent *TemporalBatchComm } s.Command.Args = cobra.NoArgs s.Command.Flags().IntVar(&s.Limit, "limit", 0, "Maximum number of batch jobs to display.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1123,10 +1107,9 @@ func NewTemporalBatchTerminateCommand(cctx *CommandContext, parent *TemporalBatc _ = cobra.MarkFlagRequired(s.Command.Flags(), "job-id") s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for terminating the batch job. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "reason") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1175,10 +1158,9 @@ func NewTemporalConfigDeleteCommand(cctx *CommandContext, parent *TemporalConfig s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Prop, "prop", "p", "", "Specific property to delete. If unset, deletes entire profile. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "prop") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1200,10 +1182,9 @@ func NewTemporalConfigDeleteProfileCommand(cctx *CommandContext, parent *Tempora s.Command.Long = "Remove a full profile entirely. The `--profile` must be set explicitly.\n\n```\ntemporal config delete-profile \\\n --profile my-profile\n```" } s.Command.Args = cobra.NoArgs - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1227,10 +1208,9 @@ func NewTemporalConfigGetCommand(cctx *CommandContext, parent *TemporalConfigCom } s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Prop, "prop", "p", "", "Specific property to get.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1252,10 +1232,9 @@ func NewTemporalConfigListCommand(cctx *CommandContext, parent *TemporalConfigCo s.Command.Long = "List profile names in the config file.\n\n```\ntemporal config list\n```" } s.Command.Args = cobra.NoArgs - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1283,10 +1262,9 @@ func NewTemporalConfigSetCommand(cctx *CommandContext, parent *TemporalConfigCom _ = cobra.MarkFlagRequired(s.Command.Flags(), "prop") s.Command.Flags().StringVarP(&s.Value, "value", "v", "", "Property value. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "value") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1333,10 +1311,9 @@ func NewTemporalEnvDeleteCommand(cctx *CommandContext, parent *TemporalEnvComman } s.Command.Args = cobra.MaximumNArgs(1) s.Command.Flags().StringVarP(&s.Key, "key", "k", "", "Property name.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1360,10 +1337,9 @@ func NewTemporalEnvGetCommand(cctx *CommandContext, parent *TemporalEnvCommand) } s.Command.Args = cobra.MaximumNArgs(1) s.Command.Flags().StringVarP(&s.Key, "key", "k", "", "Property name.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1383,10 +1359,9 @@ func NewTemporalEnvListCommand(cctx *CommandContext, parent *TemporalEnvCommand) s.Command.Args = cobra.NoArgs s.Command.Annotations = make(map[string]string) s.Command.Annotations["ignoresMissingEnv"] = "true" - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1414,10 +1389,9 @@ func NewTemporalEnvSetCommand(cctx *CommandContext, parent *TemporalEnvCommand) s.Command.Annotations["ignoresMissingEnv"] = "true" s.Command.Flags().StringVarP(&s.Key, "key", "k", "", "Property name (required).") s.Command.Flags().StringVarP(&s.Value, "value", "v", "", "Property value (required).") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1492,10 +1466,9 @@ func NewTemporalNexusOperationCancelCommand(cctx *CommandContext, parent *Tempor s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for cancellation.") s.NexusOperationReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1519,10 +1492,9 @@ func NewTemporalNexusOperationCountCommand(cctx *CommandContext, parent *Tempora } s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Query to filter Nexus Operation Executions to count.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1548,10 +1520,9 @@ func NewTemporalNexusOperationDescribeCommand(cctx *CommandContext, parent *Temp s.Command.Args = cobra.NoArgs s.Command.Flags().BoolVar(&s.Raw, "raw", false, "Print properties without changing their format.") s.NexusOperationReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1577,10 +1548,9 @@ func NewTemporalNexusOperationExecuteCommand(cctx *CommandContext, parent *Tempo s.Command.Args = cobra.NoArgs s.NexusOperationStartOptions.BuildFlags(s.Command.Flags()) s.PayloadInputOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1608,10 +1578,9 @@ func NewTemporalNexusOperationListCommand(cctx *CommandContext, parent *Temporal s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Query to filter the Nexus Operation Executions to list.") s.Command.Flags().IntVar(&s.Limit, "limit", 0, "Maximum number of Nexus Operation Executions to display.") s.Command.Flags().IntVar(&s.PageSize, "page-size", 0, "Maximum number of Nexus Operation Executions to fetch at a time from the server.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1635,10 +1604,9 @@ func NewTemporalNexusOperationResultCommand(cctx *CommandContext, parent *Tempor } s.Command.Args = cobra.NoArgs s.NexusOperationReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1664,10 +1632,9 @@ func NewTemporalNexusOperationStartCommand(cctx *CommandContext, parent *Tempora s.Command.Args = cobra.NoArgs s.NexusOperationStartOptions.BuildFlags(s.Command.Flags()) s.PayloadInputOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1693,10 +1660,9 @@ func NewTemporalNexusOperationTerminateCommand(cctx *CommandContext, parent *Tem s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for termination. Defaults to a message with the current user's name.") s.NexusOperationReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1770,10 +1736,9 @@ func NewTemporalOperatorClusterDescribeCommand(cctx *CommandContext, parent *Tem } s.Command.Args = cobra.NoArgs s.Command.Flags().BoolVar(&s.Detail, "detail", false, "Show history shard count and Cluster/Service version information.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1795,10 +1760,9 @@ func NewTemporalOperatorClusterHealthCommand(cctx *CommandContext, parent *Tempo s.Command.Long = "View information about the health of a Temporal Service:\n\n```\ntemporal operator cluster health\n```" } s.Command.Args = cobra.NoArgs - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1822,10 +1786,9 @@ func NewTemporalOperatorClusterListCommand(cctx *CommandContext, parent *Tempora } s.Command.Args = cobra.NoArgs s.Command.Flags().IntVar(&s.Limit, "limit", 0, "Maximum number of Clusters to display.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1850,10 +1813,9 @@ func NewTemporalOperatorClusterRemoveCommand(cctx *CommandContext, parent *Tempo s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Name, "name", "", "Cluster/Service name. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "name") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1875,10 +1837,9 @@ func NewTemporalOperatorClusterSystemCommand(cctx *CommandContext, parent *Tempo s.Command.Long = "Show Temporal Server information for Temporal Clusters (Service): Server\nversion, scheduling support, and more. This information helps diagnose\nproblems with the Temporal Server.\n\nThe command defaults to the local Service. Otherwise, use the\n`--frontend-address` option to specify a Cluster (Service) endpoint:\n\n```\ntemporal operator cluster system \\\n --frontend-address \"YourRemoteEndpoint:YourRemotePort\"\n```" } s.Command.Args = cobra.NoArgs - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1907,10 +1868,9 @@ func NewTemporalOperatorClusterUpsertCommand(cctx *CommandContext, parent *Tempo _ = cobra.MarkFlagRequired(s.Command.Flags(), "frontend-address") s.Command.Flags().BoolVar(&s.EnableConnection, "enable-connection", false, "Set the connection to \"enabled\".") s.Command.Flags().BoolVar(&s.EnableReplication, "enable-replication", false, "Set the replication to \"enabled\".") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -1981,10 +1941,9 @@ func NewTemporalOperatorNamespaceCreateCommand(cctx *CommandContext, parent *Tem s.VisibilityArchivalState = cliext.NewFlagStringEnum([]string{"disabled", "enabled"}, "disabled") s.Command.Flags().Var(&s.VisibilityArchivalState, "visibility-archival-state", "Visibility archival state. Accepted values: disabled, enabled.") s.Command.Flags().StringVar(&s.VisibilityUri, "visibility-uri", "", "Archive visibility data to this `URI`. Once enabled, can't be changed.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2008,10 +1967,9 @@ func NewTemporalOperatorNamespaceDeleteCommand(cctx *CommandContext, parent *Tem } s.Command.Args = cobra.MaximumNArgs(1) s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Request confirmation before deletion.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2035,10 +1993,9 @@ func NewTemporalOperatorNamespaceDescribeCommand(cctx *CommandContext, parent *T } s.Command.Args = cobra.MaximumNArgs(1) s.Command.Flags().StringVar(&s.NamespaceId, "namespace-id", "", "Namespace ID.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2060,10 +2017,9 @@ func NewTemporalOperatorNamespaceListCommand(cctx *CommandContext, parent *Tempo s.Command.Long = "Display a detailed listing for all Namespaces on the Service:\n\n```\ntemporal operator namespace list\n```" } s.Command.Args = cobra.NoArgs - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2113,10 +2069,9 @@ func NewTemporalOperatorNamespaceUpdateCommand(cctx *CommandContext, parent *Tem s.VisibilityArchivalState = cliext.NewFlagStringEnum([]string{"disabled", "enabled"}, "") s.Command.Flags().Var(&s.VisibilityArchivalState, "visibility-archival-state", "Visibility archival state. Accepted values: disabled, enabled.") s.Command.Flags().StringVar(&s.VisibilityUri, "visibility-uri", "", "Archive visibility data to this `URI`. Once enabled, can't be changed.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2186,10 +2141,9 @@ func NewTemporalOperatorNexusEndpointCreateCommand(cctx *CommandContext, parent s.Command.Args = cobra.NoArgs s.NexusEndpointIdentityOptions.BuildFlags(s.Command.Flags()) s.NexusEndpointConfigOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2213,10 +2167,9 @@ func NewTemporalOperatorNexusEndpointDeleteCommand(cctx *CommandContext, parent } s.Command.Args = cobra.NoArgs s.NexusEndpointIdentityOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2240,10 +2193,9 @@ func NewTemporalOperatorNexusEndpointGetCommand(cctx *CommandContext, parent *Te } s.Command.Args = cobra.NoArgs s.NexusEndpointIdentityOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2265,10 +2217,9 @@ func NewTemporalOperatorNexusEndpointListCommand(cctx *CommandContext, parent *T s.Command.Long = "List all Nexus Endpoints on the Server.\n\n```\ntemporal operator nexus endpoint list\n```" } s.Command.Args = cobra.NoArgs - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2296,10 +2247,9 @@ func NewTemporalOperatorNexusEndpointUpdateCommand(cctx *CommandContext, parent s.Command.Flags().BoolVar(&s.UnsetDescription, "unset-description", false, "Unset the description.") s.NexusEndpointIdentityOptions.BuildFlags(s.Command.Flags()) s.NexusEndpointConfigOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2350,10 +2300,9 @@ func NewTemporalOperatorSearchAttributeCreateCommand(cctx *CommandContext, paren s.Type = cliext.NewFlagStringEnumArray([]string{"Text", "Keyword", "Int", "Double", "Bool", "Datetime", "KeywordList"}, []string{}) s.Command.Flags().Var(&s.Type, "type", "Search Attribute type. Accepted values: Text, Keyword, Int, Double, Bool, Datetime, KeywordList. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "type") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2375,10 +2324,9 @@ func NewTemporalOperatorSearchAttributeListCommand(cctx *CommandContext, parent s.Command.Long = "Display a list of active Search Attributes that can be assigned or used with\nWorkflow Queries. You can manage this list and add attributes as needed:\n\n```\ntemporal operator search-attribute list\n```" } s.Command.Args = cobra.NoArgs - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2405,10 +2353,9 @@ func NewTemporalOperatorSearchAttributeRemoveCommand(cctx *CommandContext, paren s.Command.Flags().StringArrayVar(&s.Name, "name", nil, "Search Attribute name. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "name") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm removal.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2470,10 +2417,9 @@ func NewTemporalScheduleBackfillCommand(cctx *CommandContext, parent *TemporalSc _ = cobra.MarkFlagRequired(s.Command.Flags(), "start-time") s.OverlapPolicyOptions.BuildFlags(s.Command.Flags()) s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2510,10 +2456,9 @@ func NewTemporalScheduleCreateCommand(cctx *CommandContext, parent *TemporalSche s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "name": "type", })) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2537,10 +2482,9 @@ func NewTemporalScheduleDeleteCommand(cctx *CommandContext, parent *TemporalSche } s.Command.Args = cobra.NoArgs s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2564,10 +2508,9 @@ func NewTemporalScheduleDescribeCommand(cctx *CommandContext, parent *TemporalSc } s.Command.Args = cobra.NoArgs s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2595,10 +2538,9 @@ func NewTemporalScheduleListCommand(cctx *CommandContext, parent *TemporalSchedu s.Command.Flags().BoolVarP(&s.Long, "long", "l", false, "Show detailed information.") s.Command.Flags().BoolVar(&s.ReallyLong, "really-long", false, "Show extensive information in non-table form.") s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Filter results using given List Filter.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2628,10 +2570,9 @@ func NewTemporalScheduleListMatchingTimesCommand(cctx *CommandContext, parent *T s.Command.Flags().Var(&s.EndTime, "end-time", "End of time range to list matching times. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "end-time") s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2661,10 +2602,9 @@ func NewTemporalScheduleToggleCommand(cctx *CommandContext, parent *TemporalSche s.Command.Flags().StringVar(&s.Reason, "reason", "(no reason provided)", "Reason for pausing or unpausing the Schedule.") s.Command.Flags().BoolVar(&s.Unpause, "unpause", false, "Unpause the Schedule.") s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2690,10 +2630,9 @@ func NewTemporalScheduleTriggerCommand(cctx *CommandContext, parent *TemporalSch s.Command.Args = cobra.NoArgs s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) s.OverlapPolicyOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2728,10 +2667,9 @@ func NewTemporalScheduleUpdateCommand(cctx *CommandContext, parent *TemporalSche s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "name": "type", })) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2805,10 +2743,9 @@ func NewTemporalServerStartDevCommand(cctx *CommandContext, parent *TemporalServ s.Command.Flags().StringArrayVar(&s.DynamicConfigValue, "dynamic-config-value", nil, "Dynamic configuration value using `KEY=VALUE` pairs. Keys must be identifiers, and values must be JSON values. For example: `YourKey=\"YourString\"` Can be passed multiple times.") s.Command.Flags().BoolVar(&s.LogConfig, "log-config", false, "Print the server config to stderr.") s.Command.Flags().StringArrayVar(&s.SearchAttribute, "search-attribute", nil, "Search attributes to register using `KEY=VALUE` pairs. Keys must be identifiers, and values must be the search attribute type, which is one of the following: Text, Keyword, Int, Double, Bool, Datetime, KeywordList.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2886,10 +2823,9 @@ func NewTemporalTaskQueueConfigGetCommand(cctx *CommandContext, parent *Temporal s.TaskQueueType = cliext.NewFlagStringEnum([]string{"workflow", "activity", "nexus"}, "") s.Command.Flags().Var(&s.TaskQueueType, "task-queue-type", "Task Queue type. Accepted values: workflow, activity, nexus. Accepted values: workflow, activity, nexus. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "task-queue-type") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2932,10 +2868,9 @@ func NewTemporalTaskQueueConfigSetCommand(cctx *CommandContext, parent *Temporal s.Command.Flags().StringVar(&s.FairnessKeyRpsLimitReason, "fairness-key-rps-limit-reason", "", "Reason for fairness key rate limit update.") s.Command.Flags().StringArrayVar(&s.FairnessKeyWeight, "fairness-key-weight", nil, "Set or unset fairness key weight overrides in format key=weight or key=default. Use key=weight to set a positive weight value; use key=default to unset. Can be specified multiple times. Example: --fairness-key-weight HighPriority=2.0 --fairness-key-weight LowPriority=default.") s.Command.Flags().BoolVar(&s.FairnessKeyWeightClearAll, "fairness-key-weight-clear-all", false, "Unset all fairness key weight overrides. Cannot be used with --fairness-key-weight.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -2982,10 +2917,9 @@ func NewTemporalTaskQueueDescribeCommand(cctx *CommandContext, parent *TemporalT s.Command.Flags().IntVar(&s.PartitionsLegacy, "partitions-legacy", 1, "Query partitions 1 through `N`. Experimental/Temporary feature. Legacy mode only.") s.Command.Flags().BoolVar(&s.DisableStats, "disable-stats", false, "Disable task queue statistics.") s.Command.Flags().BoolVar(&s.ReportConfig, "report-config", false, "Include task queue configuration in the response. When enabled, the command will return the current rate limit configuration for the task queue.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3016,10 +2950,9 @@ func NewTemporalTaskQueueGetBuildIdReachabilityCommand(cctx *CommandContext, par s.ReachabilityType = cliext.NewFlagStringEnum([]string{"open", "closed", "existing"}, "existing") s.Command.Flags().Var(&s.ReachabilityType, "reachability-type", "Reachability filter. `open`: reachable by one or more open workflows. `closed`: reachable by one or more closed workflows. `existing`: reachable by either. New Workflow Executions reachable by a Build ID are always reported. Accepted values: open, closed, existing.") s.Command.Flags().StringArrayVarP(&s.TaskQueue, "task-queue", "t", nil, "Search only the specified task queue(s). Can be passed multiple times.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3048,10 +2981,9 @@ func NewTemporalTaskQueueGetBuildIdsCommand(cctx *CommandContext, parent *Tempor s.Command.Flags().StringVarP(&s.TaskQueue, "task-queue", "t", "", "Task Queue name. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "task-queue") s.Command.Flags().IntVar(&s.MaxSets, "max-sets", 0, "Max return count. Use 1 for default major version. Use 0 for all sets.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3076,10 +3008,9 @@ func NewTemporalTaskQueueListPartitionCommand(cctx *CommandContext, parent *Temp s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.TaskQueue, "task-queue", "t", "", "Task Queue name. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "task-queue") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3137,10 +3068,9 @@ func NewTemporalTaskQueueUpdateBuildIdsAddNewCompatibleCommand(cctx *CommandCont s.Command.Flags().StringVar(&s.ExistingCompatibleBuildId, "existing-compatible-build-id", "", "Pre-existing Build ID in this Task Queue. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "existing-compatible-build-id") s.Command.Flags().BoolVar(&s.SetAsDefault, "set-as-default", false, "Set the expanded Build ID set as the Task Queue default.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3170,10 +3100,9 @@ func NewTemporalTaskQueueUpdateBuildIdsAddNewDefaultCommand(cctx *CommandContext _ = cobra.MarkFlagRequired(s.Command.Flags(), "build-id") s.Command.Flags().StringVarP(&s.TaskQueue, "task-queue", "t", "", "Task Queue name. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "task-queue") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3203,10 +3132,9 @@ func NewTemporalTaskQueueUpdateBuildIdsPromoteIdInSetCommand(cctx *CommandContex _ = cobra.MarkFlagRequired(s.Command.Flags(), "build-id") s.Command.Flags().StringVarP(&s.TaskQueue, "task-queue", "t", "", "Task Queue name. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "task-queue") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3236,10 +3164,9 @@ func NewTemporalTaskQueueUpdateBuildIdsPromoteSetCommand(cctx *CommandContext, p _ = cobra.MarkFlagRequired(s.Command.Flags(), "build-id") s.Command.Flags().StringVarP(&s.TaskQueue, "task-queue", "t", "", "Task Queue name. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "task-queue") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3303,10 +3230,9 @@ func NewTemporalTaskQueueVersioningAddRedirectRuleCommand(cctx *CommandContext, s.Command.Flags().StringVar(&s.TargetBuildId, "target-build-id", "", "Target build ID. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "target-build-id") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3337,10 +3263,9 @@ func NewTemporalTaskQueueVersioningCommitBuildIdCommand(cctx *CommandContext, pa _ = cobra.MarkFlagRequired(s.Command.Flags(), "build-id") s.Command.Flags().BoolVar(&s.Force, "force", false, "Bypass recent-poller validation.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3371,10 +3296,9 @@ func NewTemporalTaskQueueVersioningDeleteAssignmentRuleCommand(cctx *CommandCont _ = cobra.MarkFlagRequired(s.Command.Flags(), "rule-index") s.Command.Flags().BoolVar(&s.Force, "force", false, "Bypass one-unconditional-rule validation.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3403,10 +3327,9 @@ func NewTemporalTaskQueueVersioningDeleteRedirectRuleCommand(cctx *CommandContex s.Command.Flags().StringVar(&s.SourceBuildId, "source-build-id", "", "Source Build ID. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "source-build-id") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3430,10 +3353,9 @@ func NewTemporalTaskQueueVersioningGetRulesCommand(cctx *CommandContext, parent s.Command.Args = cobra.NoArgs s.Command.Annotations = make(map[string]string) s.Command.Annotations["deprecationWarning"] = "This API has been deprecated by Worker Deployment." - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3466,10 +3388,9 @@ func NewTemporalTaskQueueVersioningInsertAssignmentRuleCommand(cctx *CommandCont s.Command.Flags().IntVarP(&s.RuleIndex, "rule-index", "i", 0, "Insertion position. Ranges from 0 (insert at start) to count (append). Any number greater than the count is treated as \"append\".") s.Command.Flags().IntVar(&s.Percentage, "percentage", 100, "Traffic percent to send to target Build ID.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3505,10 +3426,9 @@ func NewTemporalTaskQueueVersioningReplaceAssignmentRuleCommand(cctx *CommandCon s.Command.Flags().IntVar(&s.Percentage, "percentage", 100, "Divert percent of traffic to target Build ID.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm.") s.Command.Flags().BoolVar(&s.Force, "force", false, "Bypass the validation that one unconditional rule remains.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3540,10 +3460,9 @@ func NewTemporalTaskQueueVersioningReplaceRedirectRuleCommand(cctx *CommandConte s.Command.Flags().StringVar(&s.TargetBuildId, "target-build-id", "", "Target Build ID. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "target-build-id") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3622,10 +3541,9 @@ func NewTemporalWorkerDeploymentCreateCommand(cctx *CommandContext, parent *Temp } s.Command.Args = cobra.NoArgs s.DeploymentNameOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3655,10 +3573,9 @@ func NewTemporalWorkerDeploymentCreateVersionCommand(cctx *CommandContext, paren s.Command.Flags().StringVar(&s.AwsLambdaAssumeRoleArn, "aws-lambda-assume-role-arn", "", "AWS IAM role ARN that the Temporal server will assume when invoking the Lambda function that spawns a new Worker in this Worker Deployment Version. Required when --aws-lambda-function-arn is specified.") s.Command.Flags().StringVar(&s.AwsLambdaAssumeRoleExternalId, "aws-lambda-assume-role-external-id", "", "Temporal server will enforce that the AWS IAM trust policy associated with the AWS IAM role specified in --aws-lambda-assume-role-arn has an aws:ExternalId condition that matches the supplied value. Required when --aws-lambda-function-arn is specified.") s.DeploymentVersionOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3682,10 +3599,9 @@ func NewTemporalWorkerDeploymentDeleteCommand(cctx *CommandContext, parent *Temp } s.Command.Args = cobra.NoArgs s.DeploymentNameOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3711,10 +3627,9 @@ func NewTemporalWorkerDeploymentDeleteVersionCommand(cctx *CommandContext, paren s.Command.Args = cobra.NoArgs s.Command.Flags().BoolVar(&s.SkipDrainage, "skip-drainage", false, "Ignore the deletion requirement of not draining.") s.DeploymentVersionOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3738,10 +3653,9 @@ func NewTemporalWorkerDeploymentDescribeCommand(cctx *CommandContext, parent *Te } s.Command.Args = cobra.NoArgs s.DeploymentNameOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3767,10 +3681,9 @@ func NewTemporalWorkerDeploymentDescribeVersionCommand(cctx *CommandContext, par s.Command.Args = cobra.NoArgs s.Command.Flags().BoolVar(&s.ReportTaskQueueStats, "report-task-queue-stats", false, "Report stats for task queues that are present in this version.") s.DeploymentVersionOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3792,10 +3705,9 @@ func NewTemporalWorkerDeploymentListCommand(cctx *CommandContext, parent *Tempor s.Command.Long = "List existing Worker Deployments in the client's namespace.\n\n```\ntemporal worker deployment list [options]\n```\n\nFor example, listing Deployments in YourDeploymentNamespace:\n\n```\ntemporal worker deployment list \\\n --namespace YourDeploymentNamespace\n```" } s.Command.Args = cobra.NoArgs - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3846,10 +3758,9 @@ func NewTemporalWorkerDeploymentManagerIdentitySetCommand(cctx *CommandContext, s.Command.Flags().BoolVar(&s.Self, "self", false, "Set Manager Identity to the identity of the user submitting this request. Required unless --manager-identity is specified.") s.Command.Flags().StringVar(&s.DeploymentName, "deployment-name", "", "Name for a Worker Deployment. Required.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm set Manager Identity.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3875,10 +3786,9 @@ func NewTemporalWorkerDeploymentManagerIdentityUnsetCommand(cctx *CommandContext s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.DeploymentName, "deployment-name", "", "Name for a Worker Deployment. Required.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm unset Manager Identity.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3908,10 +3818,9 @@ func NewTemporalWorkerDeploymentSetCurrentVersionCommand(cctx *CommandContext, p s.Command.Flags().BoolVar(&s.AllowNoPollers, "allow-no-pollers", false, "Override protection and set version as current even if it has no pollers.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm set Current Version.") s.DeploymentVersionOrUnversionedOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3945,10 +3854,9 @@ func NewTemporalWorkerDeploymentSetRampingVersionCommand(cctx *CommandContext, p s.Command.Flags().BoolVar(&s.AllowNoPollers, "allow-no-pollers", false, "Override protection and set version as ramping even if it has no pollers.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm set Ramping Version.") s.DeploymentVersionOrUnversionedOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -3980,10 +3888,9 @@ func NewTemporalWorkerDeploymentUpdateVersionComputeConfigCommand(cctx *CommandC s.Command.Flags().StringVar(&s.AwsLambdaAssumeRoleExternalId, "aws-lambda-assume-role-external-id", "", "Temporal server will enforce that the AWS IAM trust policy associated with the AWS IAM role specified in --aws-lambda-assume-role-arn has an aws:ExternalId condition that matches the supplied value. Required when --aws-lambda-function-arn is specified.") s.Command.Flags().BoolVar(&s.Remove, "remove", false, "Removes any compute configuration associated with this Worker Deployment Version.") s.DeploymentVersionOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4011,10 +3918,9 @@ func NewTemporalWorkerDeploymentUpdateVersionMetadataCommand(cctx *CommandContex s.Command.Flags().StringArrayVar(&s.Metadata, "metadata", nil, "Set deployment metadata using `KEY=\"VALUE\"` pairs. Keys must be identifiers, and values must be JSON values. For example: `YourKey={\"your\": \"value\"}` Can be passed multiple times.") s.Command.Flags().StringArrayVar(&s.RemoveEntries, "remove-entries", nil, "Keys of entries to be deleted from metadata. Can be passed multiple times.") s.DeploymentVersionOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4039,10 +3945,9 @@ func NewTemporalWorkerDescribeCommand(cctx *CommandContext, parent *TemporalWork s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.WorkerInstanceKey, "worker-instance-key", "", "Worker instance key to describe. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "worker-instance-key") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4068,10 +3973,9 @@ func NewTemporalWorkerListCommand(cctx *CommandContext, parent *TemporalWorkerCo s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Content for an SQL-like `QUERY` List Filter.") s.Command.Flags().IntVar(&s.Limit, "limit", 0, "Maximum number of workers to display.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4140,10 +4044,9 @@ func NewTemporalWorkflowCancelCommand(cctx *CommandContext, parent *TemporalWork } s.Command.Args = cobra.NoArgs s.SingleWorkflowOrBatchOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4167,10 +4070,9 @@ func NewTemporalWorkflowCountCommand(cctx *CommandContext, parent *TemporalWorkf } s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Content for an SQL-like `QUERY` List Filter.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4194,10 +4096,9 @@ func NewTemporalWorkflowDeleteCommand(cctx *CommandContext, parent *TemporalWork } s.Command.Args = cobra.NoArgs s.SingleWorkflowOrBatchOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4225,10 +4126,9 @@ func NewTemporalWorkflowDescribeCommand(cctx *CommandContext, parent *TemporalWo s.Command.Flags().BoolVar(&s.ResetPoints, "reset-points", false, "Show auto-reset points only.") s.Command.Flags().BoolVar(&s.Raw, "raw", false, "Print properties without changing their format.") s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4261,10 +4161,9 @@ func NewTemporalWorkflowExecuteCommand(cctx *CommandContext, parent *TemporalWor s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "name": "type", })) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4313,10 +4212,9 @@ func NewTemporalWorkflowExecuteUpdateWithStartCommand(cctx *CommandContext, pare "name": "type", "update-type": "update-name", })) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4343,10 +4241,9 @@ func NewTemporalWorkflowFixHistoryJsonCommand(cctx *CommandContext, parent *Temp s.Command.Flags().StringVarP(&s.Source, "source", "s", "", "Path to the original file. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "source") s.Command.Flags().StringVarP(&s.Target, "target", "t", "", "Path to the results file. When omitted, output is sent to stdout.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4376,10 +4273,9 @@ func NewTemporalWorkflowListCommand(cctx *CommandContext, parent *TemporalWorkfl s.Command.Flags().BoolVar(&s.Archived, "archived", false, "Limit output to archived Workflow Executions. EXPERIMENTAL.") s.Command.Flags().IntVar(&s.Limit, "limit", 0, "Maximum number of Workflow Executions to display.") s.Command.Flags().IntVar(&s.PageSize, "page-size", 0, "Maximum number of Workflow Executions to fetch at a time from the server.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4405,10 +4301,9 @@ func NewTemporalWorkflowMetadataCommand(cctx *CommandContext, parent *TemporalWo s.Command.Args = cobra.NoArgs s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) s.QueryModifiersOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4430,10 +4325,9 @@ func NewTemporalWorkflowPauseCommand(cctx *CommandContext, parent *TemporalWorkf s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for pausing the Workflow Execution. Defaults to message with the current user's name.") s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4467,10 +4361,9 @@ func NewTemporalWorkflowQueryCommand(cctx *CommandContext, parent *TemporalWorkf s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "type": "name", })) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4517,10 +4410,9 @@ func NewTemporalWorkflowResetCommand(cctx *CommandContext, parent *TemporalWorkf s.Command.PersistentFlags().StringVar(&s.BuildId, "build-id", "", "A Build ID. Use only with the BuildId `--type`. Resets the first Workflow task processed by this ID. By default, this reset may be in a prior run, earlier than a Continue as New point.") s.Command.PersistentFlags().StringVarP(&s.Query, "query", "q", "", "Content for an SQL-like `QUERY` List Filter.") s.Command.PersistentFlags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm. Only allowed when `--query` is present.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4540,10 +4432,9 @@ func NewTemporalWorkflowResetWithWorkflowUpdateOptionsCommand(cctx *CommandConte s.Command.Long = "Run Workflow Update Options atomically after the Workflow is reset.\nWorkflows selected by the reset command are forwarded onto the subcommand." s.Command.Args = cobra.NoArgs s.WorkflowUpdateOptionsOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4567,10 +4458,9 @@ func NewTemporalWorkflowResultCommand(cctx *CommandContext, parent *TemporalWork } s.Command.Args = cobra.NoArgs s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4600,10 +4490,9 @@ func NewTemporalWorkflowShowCommand(cctx *CommandContext, parent *TemporalWorkfl s.Command.Flags().BoolVar(&s.Detailed, "detailed", false, "Display events as detailed sections instead of table. Does not apply to JSON output.") s.Command.Flags().BoolVar(&s.Reverse, "reverse", false, "Fetch Event History newest-event-first. Cannot be combined with --follow.") s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4635,10 +4524,9 @@ func NewTemporalWorkflowSignalCommand(cctx *CommandContext, parent *TemporalWork s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "type": "name", })) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4681,10 +4569,9 @@ func NewTemporalWorkflowSignalWithStartCommand(cctx *CommandContext, parent *Tem "name": "type", "signal-type": "signal-name", })) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4711,10 +4598,9 @@ func NewTemporalWorkflowStackCommand(cctx *CommandContext, parent *TemporalWorkf s.RejectCondition = cliext.NewFlagStringEnum([]string{"not_open", "not_completed_cleanly"}, "") s.Command.Flags().Var(&s.RejectCondition, "reject-condition", "Optional flag to reject Queries based on Workflow state. Accepted values: not_open, not_completed_cleanly.") s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4745,10 +4631,9 @@ func NewTemporalWorkflowStartCommand(cctx *CommandContext, parent *TemporalWorkf s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "name": "type", })) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4801,10 +4686,9 @@ func NewTemporalWorkflowStartUpdateWithStartCommand(cctx *CommandContext, parent "name": "type", "update-type": "update-name", })) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4838,10 +4722,9 @@ func NewTemporalWorkflowTerminateCommand(cctx *CommandContext, parent *TemporalW s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for termination. Defaults to message with the current user's name.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm termination. Can only be used with --query.") s.Command.Flags().Float32Var(&s.Rps, "rps", 0, "Limit batch's requests per second. Only allowed if query is present.") - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4873,10 +4756,9 @@ func NewTemporalWorkflowTraceCommand(cctx *CommandContext, parent *TemporalWorkf s.Command.Flags().IntVar(&s.Depth, "depth", -1, "Set depth for your Child Workflow fetches. Pass -1 to fetch child workflows at any depth.") s.Command.Flags().IntVar(&s.Concurrency, "concurrency", 10, "Number of Workflow Histories to fetch at a time.") s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4898,10 +4780,9 @@ func NewTemporalWorkflowUnpauseCommand(cctx *CommandContext, parent *TemporalWor s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for unpausing the Workflow Execution. Defaults to message with the current user's name.") s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4944,10 +4825,9 @@ func NewTemporalWorkflowUpdateDescribeCommand(cctx *CommandContext, parent *Temp } s.Command.Args = cobra.NoArgs s.UpdateTargetingOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -4976,10 +4856,9 @@ func NewTemporalWorkflowUpdateExecuteCommand(cctx *CommandContext, parent *Tempo s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "type": "name", })) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -5003,10 +4882,9 @@ func NewTemporalWorkflowUpdateResultCommand(cctx *CommandContext, parent *Tempor } s.Command.Args = cobra.NoArgs s.UpdateTargetingOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -5039,10 +4917,9 @@ func NewTemporalWorkflowUpdateStartCommand(cctx *CommandContext, parent *Tempora s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "type": "name", })) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } @@ -5074,10 +4951,9 @@ func NewTemporalWorkflowUpdateOptionsCommand(cctx *CommandContext, parent *Tempo s.Command.Flags().StringVar(&s.VersioningOverrideDeploymentName, "versioning-override-deployment-name", "", "When overriding to a `pinned` or `one_time` behavior, specifies the Deployment Name of the version to target.") s.Command.Flags().StringVar(&s.VersioningOverrideBuildId, "versioning-override-build-id", "", "When overriding to a `pinned` or `one_time` behavior, specifies the Build ID of the version to target.") s.SingleWorkflowOrBatchOptions.BuildFlags(s.Command.Flags()) - s.Command.Run = func(c *cobra.Command, args []string) { - if err := s.run(cctx, args); err != nil { - cctx.Options.Fail(err) - } + s.Command.RunE = func(c *cobra.Command, args []string) error { + cctx.commandRunStarted = true + return s.run(cctx, args) } return &s } diff --git a/internal/temporalcli/commands.go b/internal/temporalcli/commands.go index 7ec38e187..01238a212 100644 --- a/internal/temporalcli/commands.go +++ b/internal/temporalcli/commands.go @@ -59,6 +59,8 @@ type CommandContext struct { // that cobra does not properly exit nonzero if an unknown command/subcommand is given. ActuallyRanCommand bool preRunSucceeded bool + commandRunStarted bool + terminalColor bool // Root/current command only set inside of pre-run RootCommand *TemporalCommand @@ -86,10 +88,6 @@ type CommandOptions struct { // If nil, [envconfig.EnvLookupOS] is used. EnvLookup envconfig.EnvLookup - // Fail is used by generated Run callbacks to hand their final error to the - // command runtime. Execute replaces it with a command-scoped recorder. - Fail func(error) - AdditionalClientGRPCDialOptions []grpc.DialOption } @@ -353,19 +351,17 @@ func (c *CommandContext) promptString(message string, expected string, autoConfi // exit boundary. Existing success-output broken-pipe exits in printer are a // separate compatibility path outside terminal error handling. func Execute(ctx context.Context, options CommandOptions) Result { - var recorder commandErrorRecorder - options.Fail = recorder.Record origNoColor := color.NoColor defer func() { color.NoColor = origNoColor }() // Create context and run. We always get a context and cancel func back even - // if an error was returned. This is so we can use the context to print an - // error message using the appropriate Fail() method, regardless of why the - // failure occurred. + // if an error was returned. This lets the terminal handler use the resolved + // streams and options regardless of why the failure occurred. // // (In most cases, an error here likely means a problem with the user's env // config file, or some other issue in their environment.) cctx, cancel, err := NewCommandContext(ctx, options) + cctx.terminalColor = !origNoColor defer cancel() defer func() { if cctx.commandCancel != nil { @@ -393,21 +389,14 @@ func Execute(ctx context.Context, options CommandOptions) Result { if unknownCommandPath(&cmd.Command, cctx.Options.Args) { return finishCommand(cctx, fmt.Errorf("unknown command"), usageForArgs(&cmd.Command, nil)) } - var cobraErr error - recorded := runRecordedCommand(func() { cobraErr = cmd.Command.ExecuteContext(cctx) }) - if recorded { - return finishCommand(cctx, recorder.Err(), "") - } + cobraErr := cmd.Command.ExecuteContext(cctx) if cobraErr != nil { usage := "" - if !cctx.ActuallyRanCommand || cctx.preRunSucceeded { + if !cctx.ActuallyRanCommand || (cctx.preRunSucceeded && !cctx.commandRunStarted) { usage = usageForArgs(&cmd.Command, cctx.Options.Args) } return finishCommand(cctx, cobraErr, usage) } - if err = recorder.Err(); err != nil { - return finishCommand(cctx, err, "") - } } } @@ -503,7 +492,7 @@ func finishCommand(cctx *CommandContext, err error, usage string) Result { func (c *CommandContext) terminalColorEnabled() bool { jsonOutput := c.JSONOutput - colorEnabled := !color.NoColor + colorEnabled := c.terminalColor for i := 0; i < len(c.Options.Args); i++ { name, value, inline := strings.Cut(c.Options.Args[i], "=") if !inline && i+1 < len(c.Options.Args) && (name == "-o" || name == "--output" || name == "--color") { diff --git a/internal/temporalcli/connectdiag.go b/internal/temporalcli/connectdiag.go index 1009b135d..cd96c5354 100644 --- a/internal/temporalcli/connectdiag.go +++ b/internal/temporalcli/connectdiag.go @@ -34,10 +34,9 @@ const ( causeClientCertRequired causeCAVerify causeHostnameMismatch - // causeCertFileUnreadable is set by the caller when building client - // options fails on a file read; the probe never runs for it. causeCertFileUnreadable - causeAuth + causeUnauthenticated + causePermissionDenied causeTimeout ) @@ -46,6 +45,8 @@ type diagStatus int const ( diagOK diagStatus = iota diagFail + diagInconclusive + diagSkipped ) type diagStage struct { @@ -75,13 +76,23 @@ const ( // handshake to detect a TLS-only server, which may appear in server logs). func diagnoseConnection(ctx context.Context, address string, tlsCfg *tls.Config, origErr error) *connectDiagnosis { d := &connectDiagnosis{Address: address, Cause: causeUnknown} + origCause, origDetail := classifyGRPCError(origErr) + switch origCause { + case causeUnauthenticated: + d.Cause, d.Detail = origCause, origDetail + d.fail("gRPC request was unauthenticated") + return d + case causePermissionDenied: + d.Cause, d.Detail = origCause, origDetail + d.fail("gRPC request was denied") + return d + } ctx, cancel := context.WithTimeout(ctx, connectDiagnosisBudget) defer cancel() host, _, err := net.SplitHostPort(address) if err != nil { - // Not a host:port we can probe; fall back to classifying the original - // error only. + d.inconclusive("Connection checks unavailable: address is not in host:port form") d.Cause, d.Detail = classifyGRPCError(origErr) return d } @@ -90,20 +101,31 @@ func diagnoseConnection(ctx context.Context, address string, tlsCfg *tls.Config, if net.ParseIP(host) == nil { addrs, err := net.DefaultResolver.LookupHost(ctx, host) if err != nil { + if d.interrupted(ctx, "DNS") { + return d + } d.fail(fmt.Sprintf("DNS lookup for %q failed: %v", host, dnsErrShort(err))) d.Cause = causeDNS return d } + if d.interrupted(ctx, "DNS") { + return d + } plural := "es" if len(addrs) == 1 { plural = "" } d.ok(fmt.Sprintf("DNS resolved (%d address%s)", len(addrs), plural)) + } else { + d.skipped("DNS lookup skipped: address uses an IP literal") } // Stage: TCP conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", address) if err != nil { + if d.interrupted(ctx, "TCP") { + return d + } if isConnRefused(err) { d.fail("TCP connection refused: nothing is listening at " + address) d.Cause = causeTCPRefused @@ -111,8 +133,8 @@ func diagnoseConnection(ctx context.Context, address string, tlsCfg *tls.Config, d.fail("TCP connection timed out") d.Cause = causeTCPTimeout } else { - d.fail(fmt.Sprintf("TCP connection failed: %v", err)) - d.Cause = causeTCPTimeout + d.inconclusive(fmt.Sprintf("TCP check inconclusive: %v", err)) + d.Cause = causeUnknown } return d } @@ -121,6 +143,9 @@ func diagnoseConnection(ctx context.Context, address string, tlsCfg *tls.Config, if tlsCfg != nil { d.probeTLS(ctx, conn, host, tlsCfg) + if ctx.Err() != nil { + return d + } if d.Cause != causeUnknown { return d } @@ -128,14 +153,21 @@ func diagnoseConnection(ctx context.Context, address string, tlsCfg *tls.Config, d.fail("server expects TLS, but the CLI is connecting without it" + detail) d.Cause = cause return d + } else if d.interrupted(ctx, "TLS") { + return d } // Stage: gRPC — no re-dial; classify the original error. - d.Cause, d.Detail = classifyGRPCError(origErr) - if d.Cause == causeAuth { - d.fail("gRPC authentication failed") - } else { - d.fail("gRPC connection failed: " + shortErr(origErr)) + d.Cause, d.Detail = origCause, origDetail + switch d.Cause { + case causeUnauthenticated: + d.fail("gRPC request was unauthenticated") + case causePermissionDenied: + d.fail("gRPC request was denied") + case causeTimeout: + d.inconclusive("gRPC failure could not be diagnosed before its deadline") + default: + d.inconclusive("gRPC failure was not classified: " + shortErr(origErr)) } return d } @@ -151,6 +183,9 @@ func (d *connectDiagnosis) probeTLS(ctx context.Context, conn net.Conn, host str } tlsConn := tls.Client(conn, cfg) if err := tlsConn.HandshakeContext(ctx); err != nil { + if d.interrupted(ctx, "TLS handshake") { + return + } d.classifyTLSError(err) return } @@ -160,6 +195,10 @@ func (d *connectDiagnosis) probeTLS(ctx context.Context, conn net.Conn, host str defer stopCancel() buf := make([]byte, 1) _, err := tlsConn.Read(buf) + if ctx.Err() != nil { + d.interrupted(ctx, "TLS post-handshake") + return + } if err != nil && !isTimeout(err) { if cause := classifyTLSAlert(err); cause != causeUnknown { d.classifyTLSError(err) @@ -203,19 +242,18 @@ func (d *connectDiagnosis) classifyTLSError(err error) { } } -// classifyTLSAlert detects remote TLS alerts that indicate the server wants a -// (different) client certificate. Go does not export alert types for remote -// errors, so this matches the alert descriptions crypto/tls emits: alert 116 -// "certificate required" (TLS 1.3), alert 42 "bad certificate", and alert 40 -// "handshake failure" (how TLS 1.2 servers commonly reject missing client -// certs). Matching is scoped to TLS probe errors only; if these strings drift -// in a future Go release, unit tests pin them and the diagnosis degrades to -// showing the raw error without a suggestion. +// classifyTLSAlert detects remote TLS alerts that explicitly indicate the +// server required or rejected a client certificate. Go does not export alert +// types for remote errors, so this matches the alert descriptions crypto/tls +// emits: alert 116 "certificate required" (TLS 1.3) and alert 42 "bad +// certificate". Generic alerts such as alert 40 "handshake failure" are not +// sufficient evidence of mTLS. Matching is scoped to TLS probe errors only; +// if these strings drift in a future Go release, unit tests pin them and the +// diagnosis degrades to showing the raw error without a suggestion. func classifyTLSAlert(err error) connectCause { msg := err.Error() if strings.Contains(msg, "certificate required") || - strings.Contains(msg, "bad certificate") || - strings.Contains(msg, "handshake failure") { + strings.Contains(msg, "bad certificate") { return causeClientCertRequired } return causeUnknown @@ -242,14 +280,14 @@ func classifyGRPCError(err error) (connectCause, string) { if errors.As(err, &deadlineErr) || errors.Is(err, context.DeadlineExceeded) { return causeTimeout, "" } - if unwrapped := errors.Unwrap(err); unwrapped != nil { - if st, ok := status.FromError(unwrapped); ok { - switch st.Code() { - case codes.Unauthenticated, codes.PermissionDenied: - return causeAuth, "" - case codes.DeadlineExceeded: - return causeTimeout, "" - } + if st, ok := status.FromError(err); ok { + switch st.Code() { + case codes.Unauthenticated: + return causeUnauthenticated, "" + case codes.PermissionDenied: + return causePermissionDenied, "" + case codes.DeadlineExceeded: + return causeTimeout, "" } } return causeUnknown, "" @@ -259,6 +297,26 @@ func (d *connectDiagnosis) ok(label string) { d.Stages = append(d.Stages, diagSt func (d *connectDiagnosis) fail(label string) { d.Stages = append(d.Stages, diagStage{diagFail, label}) } +func (d *connectDiagnosis) inconclusive(label string) { + d.Stages = append(d.Stages, diagStage{diagInconclusive, label}) +} +func (d *connectDiagnosis) skipped(label string) { + d.Stages = append(d.Stages, diagStage{diagSkipped, label}) +} + +func (d *connectDiagnosis) interrupted(ctx context.Context, stage string) bool { + if ctx.Err() == nil { + return false + } + d.Cause = causeUnknown + d.Detail = "" + if errors.Is(ctx.Err(), context.Canceled) { + d.skipped(stage + " check skipped: diagnosis canceled") + } else { + d.inconclusive(stage + " check inconclusive: diagnosis deadline reached") + } + return true +} // isConnRefused reports whether err is a refused TCP connection. errors.Is // against syscall.ECONNREFUSED matches on unix, but Windows dials fail with diff --git a/internal/temporalcli/connectdiag_test.go b/internal/temporalcli/connectdiag_test.go index babb900f0..27feca3a0 100644 --- a/internal/temporalcli/connectdiag_test.go +++ b/internal/temporalcli/connectdiag_test.go @@ -110,15 +110,44 @@ func TestDiagnoseConnection_ClientCertRequired(t *testing.T) { // Client trusts the server CA but has no client cert. d := testDiagnose(t, addr, &tls.Config{RootCAs: certPool(t, cert)}) - // This test also pins the Go crypto/tls alert text ("certificate - // required" / "bad certificate" / "handshake failure") that - // classifyTLSAlert depends on; if it fails after a Go upgrade, revisit - // classifyTLSAlert. + // This test also pins the authoritative Go crypto/tls alert text + // ("certificate required" / "bad certificate") that classifyTLSAlert + // depends on; if it fails after a Go upgrade, revisit classifyTLSAlert. assert.Equal(t, causeClientCertRequired, d.Cause) requireStage(t, d, diagOK, "TCP connection established") requireStage(t, d, diagFail, "server requires mTLS") } +func TestGenericTLSHandshakeFailureDoesNotImplyClientCertificate(t *testing.T) { + err := errors.New("remote error: tls: handshake failure") + assert.Equal(t, causeUnknown, classifyTLSAlert(err)) + + d := &connectDiagnosis{} + d.classifyTLSError(err) + assert.Equal(t, causeUnknown, d.Cause) + requireStage(t, d, diagFail, "remote error: tls: handshake failure") + assert.Nil(t, suggestAction(d, connectMeta{Address: "example:7233"})) + for _, stage := range d.Stages { + assert.NotContains(t, stage.Label, "mTLS") + assert.NotContains(t, stage.Label, "client certificate") + } + + client, server := net.Pipe() + t.Cleanup(func() { _ = client.Close() }) + go func() { + defer server.Close() + buf := make([]byte, 4096) + _, _ = server.Read(buf) + // TLS alert record: fatal handshake_failure (alert 40). + _, _ = server.Write([]byte{21, 3, 3, 0, 2, 2, 40}) + }() + ctx, cancel := context.WithTimeout(t.Context(), time.Second) + defer cancel() + cause, detail := probeServerSpeaksTLS(ctx, client, "example.com") + assert.Equal(t, causeUnknown, cause) + assert.Empty(t, detail) +} + func TestDiagnoseConnection_ServerPlaintext(t *testing.T) { // Plain TCP listener that immediately writes a non-TLS response. ln, err := net.Listen("tcp", "127.0.0.1:0") @@ -171,6 +200,7 @@ func TestDiagnoseConnection_Refused(t *testing.T) { d := testDiagnose(t, addr, nil) assert.Equal(t, causeTCPRefused, d.Cause) + requireStage(t, d, diagSkipped, "DNS lookup skipped") requireStage(t, d, diagFail, "TCP connection refused") } @@ -199,7 +229,40 @@ func TestDiagnoseConnection_HealthyTLSFallsThroughToGRPC(t *testing.T) { d := testDiagnose(t, addr, &tls.Config{RootCAs: certPool(t, cert)}) assert.Equal(t, causeTimeout, d.Cause) requireStage(t, d, diagOK, "TLS handshake succeeded") - requireStage(t, d, diagFail, "gRPC connection failed") + requireStage(t, d, diagInconclusive, "gRPC failure could not be diagnosed") +} + +func TestDiagnoseConnection_CancellationDuringTLSIsSkippedAndPrompt(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { _ = ln.Close() }) + accepted := make(chan struct{}) + release := make(chan struct{}) + go func() { + conn, acceptErr := ln.Accept() + if acceptErr != nil { + return + } + defer conn.Close() + close(accepted) + <-release + }() + t.Cleanup(func() { close(release) }) + + ctx, cancel := context.WithCancel(t.Context()) + go func() { + <-accepted + cancel() + }() + started := time.Now() + d := diagnoseConnection(ctx, ln.Addr().String(), &tls.Config{InsecureSkipVerify: true}, errors.New("dial failed")) + + assert.Less(t, time.Since(started), time.Second) + assert.Equal(t, causeUnknown, d.Cause) + requireStage(t, d, diagSkipped, "TLS handshake check skipped: diagnosis canceled") + for _, stage := range d.Stages { + assert.False(t, stage.Status == diagOK && strings.Contains(stage.Label, "TLS handshake succeeded")) + } } func requireStage(t *testing.T, d *connectDiagnosis, status diagStatus, labelSubstr string) { @@ -232,12 +295,12 @@ func TestClassifyGRPCError(t *testing.T) { { name: "unauthenticated status", err: fmt.Errorf("failed reaching server: %w", status.Error(codes.Unauthenticated, "bad credentials")), - cause: causeAuth, + cause: causeUnauthenticated, }, { name: "permission denied status", err: fmt.Errorf("failed reaching server: %w", status.Error(codes.PermissionDenied, "nope")), - cause: causeAuth, + cause: causePermissionDenied, }, { name: "unclassified error", @@ -256,6 +319,27 @@ func TestClassifyGRPCError(t *testing.T) { } } +func TestDiagnoseConnection_AuthStatusesUseTypedOriginalErrorWithoutSpeculativeProbe(t *testing.T) { + for _, tt := range []struct { + name string + code codes.Code + cause connectCause + stage string + }{ + {name: "unauthenticated", code: codes.Unauthenticated, cause: causeUnauthenticated, stage: "unauthenticated"}, + {name: "permission denied", code: codes.PermissionDenied, cause: causePermissionDenied, stage: "denied"}, + } { + t.Run(tt.name, func(t *testing.T) { + d := diagnoseConnection(t.Context(), "does-not-exist.invalid:7233", nil, + fmt.Errorf("failed reaching server: %w", status.Error(tt.code, "server-controlled detail"))) + assert.Equal(t, tt.cause, d.Cause) + require.Len(t, d.Stages, 1, "typed auth failures must not trigger post-failure network probes") + requireStage(t, d, diagFail, tt.stage) + assert.Nil(t, suggestAction(d, connectMeta{Address: d.Address})) + }) + } +} + func TestConnectSummary(t *testing.T) { origErr := fmt.Errorf("failed reaching server: context deadline exceeded") tests := []struct { @@ -281,9 +365,15 @@ func TestConnectSummary(t *testing.T) { } } -func TestAuthSummaryDoesNotCopyServerControlledStatusMessage(t *testing.T) { - d := &connectDiagnosis{Cause: causeAuth, Detail: "attacker text\nrun this"} - assert.Equal(t, "authentication failed", connectSummary(d, errors.New("fallback attacker text"))) +func TestAuthSummariesDoNotCopyServerControlledStatusMessage(t *testing.T) { + assert.Equal(t, "authentication failed", connectSummary( + &connectDiagnosis{Cause: causeUnauthenticated, Detail: "attacker text\nrun this"}, + errors.New("fallback attacker text"), + )) + assert.Equal(t, "permission denied", connectSummary( + &connectDiagnosis{Cause: causePermissionDenied, Detail: "attacker text\nrun this"}, + errors.New("fallback attacker text"), + )) } func TestArmReadDeadlineUsesEarlierContextDeadlineAndCancellation(t *testing.T) { @@ -319,10 +409,6 @@ func (c *deadlineRecordingConn) SetReadDeadline(d time.Time) error { func (c *deadlineRecordingConn) deadline() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.d } func TestSuggestFix(t *testing.T) { - cloudMeta := connectMeta{ - CommandPath: []string{"workflow", "list"}, - Address: "foo.bar.tmprl.cloud:7233", - } tests := []struct { name string diag *connectDiagnosis @@ -330,16 +416,10 @@ func TestSuggestFix(t *testing.T) { contains []string }{ { - name: "mTLS on cloud endpoint", - diag: &connectDiagnosis{Cause: causeClientCertRequired}, - meta: cloudMeta, - contains: []string{"Temporal Cloud", "tls.client_cert_path --value YourCert.pem", "tls.client_key_path --value YourKey.pem"}, - }, - { - name: "mTLS on generic endpoint", + name: "mTLS uses long flags", diag: &connectDiagnosis{Cause: causeClientCertRequired}, - meta: connectMeta{CommandPath: []string{"workflow", "list"}, Address: "myhost:7233"}, - contains: []string{"requires client certificates", "tls.client_cert_path --value YourCert.pem"}, + meta: connectMeta{Address: "myhost:7233"}, + contains: []string{"requires client certificates", "--tls-cert-path", "--tls-key-path"}, }, { name: "refused on local default port", @@ -354,16 +434,16 @@ func TestSuggestFix(t *testing.T) { contains: []string{"Nothing is listening at myhost:9999"}, }, { - name: "dns failure from profile address", + name: "dns failure does not guess provenance", diag: &connectDiagnosis{Cause: causeDNS}, - meta: connectMeta{Address: "typo.example.com:7233", AddressSource: "profile", ProfileName: "prod"}, - contains: []string{`Could not resolve "typo.example.com"`, `profile "prod"`, "temporal config get --prop address --profile prod"}, + meta: connectMeta{Address: "typo.example.com:7233"}, + contains: []string{`Could not resolve "typo.example.com"`}, }, { name: "server speaks TLS", diag: &connectDiagnosis{Cause: causeServerSpeaksTLS}, - meta: connectMeta{CommandPath: []string{"workflow", "list"}, Address: "myhost:7233"}, - contains: []string{"requires TLS", "temporal config set --prop tls --value true"}, + meta: connectMeta{Address: "myhost:7233"}, + contains: []string{"requires TLS", "--tls"}, }, { name: "server plaintext", @@ -371,12 +451,6 @@ func TestSuggestFix(t *testing.T) { meta: connectMeta{Address: "myhost:7233", TLSConfigured: true}, contains: []string{"does not appear to use TLS"}, }, - { - name: "api key rejected", - diag: &connectDiagnosis{Cause: causeAuth}, - meta: connectMeta{Address: "us-west-2.aws.api.temporal.io:7233", HasAPIKey: true}, - contains: []string{"rejected the configured API key"}, - }, { name: "cert file unreadable", diag: &connectDiagnosis{Cause: causeCertFileUnreadable, Detail: "/nope.pem"}, @@ -392,6 +466,10 @@ func TestSuggestFix(t *testing.T) { } }) } + assert.Nil(t, suggestAction(&connectDiagnosis{Cause: causeUnauthenticated}, connectMeta{Address: "example:7233"})) + assert.Nil(t, suggestAction(&connectDiagnosis{Cause: causePermissionDenied}, connectMeta{Address: "example:7233"})) + assert.Nil(t, suggestAction(&connectDiagnosis{Cause: causeTimeout}, connectMeta{Address: "example:7233"})) + assert.Nil(t, suggestAction(&connectDiagnosis{Cause: causeUnknown}, connectMeta{Address: "example:7233"})) } func TestConnectErrorRendering(t *testing.T) { @@ -406,16 +484,19 @@ func TestConnectErrorRendering(t *testing.T) { }, } err := newConnectError(d, connectMeta{ - CommandPath: []string{"workflow", "list"}, - Address: "foo.bar.tmprl.cloud:7233", + Address: "foo.bar.tmprl.cloud:7233", + Namespace: "example.namespace", + TLSConfigured: true, }, origErr) - msg := string(renderErrorText(err.report(), renderOptions{})) + msg := string(renderErrorText(connectionErrorReport(err), renderOptions{})) assert.Contains(t, msg, "failed connecting to Temporal server at foo.bar.tmprl.cloud:7233: TLS handshake failed: server requires client certificate (mTLS)") - assert.Contains(t, msg, "Connecting to foo.bar.tmprl.cloud:7233") + assert.Contains(t, msg, "Namespace: example.namespace") + assert.Contains(t, msg, "TLS: true") + assert.Contains(t, msg, "Connection checks for foo.bar.tmprl.cloud:7233") assert.Contains(t, msg, "✓ DNS resolved (3 addresses)") assert.Contains(t, msg, "✗ TLS handshake failed") - assert.Contains(t, msg, "tls.client_cert_path --value YourCert.pem") + assert.Contains(t, msg, "--tls-cert-path") // Unwrap preserves the original error. assert.ErrorContains(t, err.Unwrap(), "context deadline exceeded") } diff --git a/internal/temporalcli/connecterror.go b/internal/temporalcli/connecterror.go index 5f01086f6..15d4b7da9 100644 --- a/internal/temporalcli/connecterror.go +++ b/internal/temporalcli/connecterror.go @@ -3,7 +3,6 @@ package temporalcli import ( "fmt" "net" - "strings" ) // connectError is returned by dialClient when connecting to the server fails. @@ -16,21 +15,19 @@ type connectError struct { } func (e *connectError) Error() string { + if e.meta.Address == "" { + return "failed preparing Temporal server connection: " + connectSummary(&e.diagnosis, e.cause) + } return fmt.Sprintf("failed connecting to Temporal server at %s: %s", e.meta.Address, connectSummary(&e.diagnosis, e.cause)) } + func (e *connectError) Unwrap() error { return e.cause } -// connectMeta contains only allowlisted connection provenance. Raw argv and -// credential values must never enter this value. +// connectMeta contains the effective non-secret connection settings returned +// by the same ClientOptionsBuilder.Build call used for the failed dial. type connectMeta struct { - // CommandPath is retained only as non-sensitive provenance for callers that - // still populate it. Suggestions never replay the current command. - CommandPath []string Address string - AddressSource string // "flag", "profile", or "default" - ProfileName string - HasAPIKey bool - HasOAuth bool + Namespace string TLSConfigured bool } @@ -38,23 +35,9 @@ func newConnectError(d *connectDiagnosis, meta connectMeta, origErr error) *conn if meta.Address == "" { meta.Address = d.Address } - return &connectError{diagnosis: *d, meta: meta, cause: origErr} -} - -func (e *connectError) report() errorReport { - report := errorReport{ - Summary: e.Error(), - CheckHeading: "Connecting to " + e.meta.Address, - Action: suggestAction(&e.diagnosis, e.meta), - } - for _, stage := range e.diagnosis.Stages { - outcome := checkFailed - if stage.Status == diagOK { - outcome = checkSucceeded - } - report.Checks = append(report.Checks, errorCheck{Outcome: outcome, Message: stage.Label}) - } - return report + diagnosis := *d + diagnosis.Stages = append([]diagStage(nil), d.Stages...) + return &connectError{diagnosis: diagnosis, meta: meta, cause: origErr} } // connectSummary is the one-line cause appended to the first error line. For @@ -80,99 +63,52 @@ func connectSummary(d *connectDiagnosis, origErr error) string { return "server TLS certificate does not match host" case causeCertFileUnreadable: return fmt.Sprintf("cannot read file %q", d.Detail) - case causeAuth: + case causeUnauthenticated: return "authentication failed" + case causePermissionDenied: + return "permission denied" default: return shortErr(origErr) } } -// suggestAction maps a classified failure to one typed next step. Invocation -// arguments are known literals plus safe command metadata, never raw argv. +// suggestAction maps only directly observed failures to a typed next step. +// It never replays argv or guesses credential kind or configuration provenance. func suggestAction(d *connectDiagnosis, meta connectMeta) *displayAction { host, port, _ := net.SplitHostPort(meta.Address) switch d.Cause { case causeCertFileUnreadable: return &displayAction{Label: fmt.Sprintf("Cannot read %q — check that the path exists and is readable.", d.Detail)} case causeClientCertRequired: - message := "The server requires client certificates (mTLS). Configure both certificate paths:" - if isCloudHost(host) { - message = "This looks like a Temporal Cloud endpoint secured with mTLS. Provide client certificates:" - } - if strings.HasSuffix(host, ".api.temporal.io") { - message += " If the namespace uses API-key auth instead, pass --api-key." - } - return configAction(message, meta, - []string{"--prop", "tls.client_cert_path", "--value", "YourCert.pem"}, - []string{"--prop", "tls.client_key_path", "--value", "YourKey.pem"}) - case causeAuth: - if meta.HasAPIKey && meta.HasOAuth { - return &displayAction{Label: "The server rejected the configured API key or OAuth credentials. Verify the active credential and namespace gRPC endpoint."} - } - if meta.HasAPIKey { - return &displayAction{Label: "The server rejected the configured API key. Verify it and the namespace gRPC endpoint."} - } - if meta.HasOAuth { - return &displayAction{Label: "The server rejected the configured OAuth credentials. Verify them and the namespace gRPC endpoint."} - } - return &displayAction{Label: "The server rejected the request as unauthenticated. Configure an API key or mTLS credentials."} + return &displayAction{Label: "The server requires client certificates (mTLS). Configure both --tls-cert-path and --tls-key-path."} + case causeUnauthenticated, causePermissionDenied: + // client.Options keeps credentials opaque, so no credential-specific + // action is authoritative here. + return nil case causeServerPlaintext: - return &displayAction{Label: fmt.Sprintf("The server at %s does not appear to use TLS. Remove TLS settings or check the address.", meta.Address)} + return &displayAction{Label: fmt.Sprintf("The server at %s does not appear to use TLS. Remove --tls and related TLS flags, or check the address.", meta.Address)} case causeServerSpeaksTLS: - message := "The server requires TLS. Add --tls:" - if isCloudHost(host) { - message += " Temporal Cloud also requires client credentials." - } - return configAction(message, meta, []string{"--prop", "tls", "--value", "true"}) + return &displayAction{Label: "The server requires TLS. Retry with --tls."} case causeDNS: - s := fmt.Sprintf("Could not resolve %q — check the server address.", host) - if meta.AddressSource == "profile" { - profile := meta.ProfileName - if profile == "" { - profile = "default" - } - s += fmt.Sprintf(" The address comes from config profile %q.", profile) - return &displayAction{Label: s, Invocations: []displayInvocation{{Command: []string{"temporal", "config", "get"}, Args: appendProfile([]string{"--prop", "address"}, profile)}}} - } - return &displayAction{Label: s} + return &displayAction{Label: fmt.Sprintf("Could not resolve %q — check the server address.", host)} case causeTCPRefused: if isLoopbackHost(host) && port == "7233" { - return &displayAction{Label: fmt.Sprintf("No Temporal server is running at %s. Start a local dev server:", meta.Address), Invocations: []displayInvocation{{Command: []string{"temporal", "server", "start-dev"}}}} + return &displayAction{ + Label: fmt.Sprintf("No Temporal server is running at %s. Start a local dev server:", meta.Address), + Invocations: []displayInvocation{{Command: []string{"temporal", "server", "start-dev"}}}, + } } return &displayAction{Label: fmt.Sprintf("Nothing is listening at %s — verify the address and that the server is running.", meta.Address)} case causeCAVerify: - return configAction("The server certificate is not trusted. Configure its private CA:", meta, []string{"--prop", "tls.server_ca_cert_path", "--value", "YourServerCA.pem"}) + return &displayAction{Label: "The server certificate is not trusted. Configure its CA certificate with --tls-ca-path."} case causeHostnameMismatch: return &displayAction{Label: fmt.Sprintf("The server certificate is not valid for %q. Set --tls-server-name to a certificate name.", host)} - case causeTCPTimeout, causeTimeout: - return &displayAction{Label: fmt.Sprintf("The connection stalled. Verify the address and network path to %s.", meta.Address)} + case causeTCPTimeout: + return &displayAction{Label: fmt.Sprintf("The TCP check timed out. Verify the address and network path to %s.", meta.Address)} } return nil } -func configAction(label string, meta connectMeta, propertyArgs ...[]string) *displayAction { - action := &displayAction{Label: label} - for _, args := range propertyArgs { - action.Invocations = append(action.Invocations, displayInvocation{ - Command: []string{"temporal", "config", "set"}, - Args: appendProfile(args, meta.ProfileName), - }) - } - return action -} - -func appendProfile(args []string, profile string) []string { - result := append([]string(nil), args...) - if profile != "" { - result = append(result, "--profile", profile) - } - return result -} - -func isCloudHost(host string) bool { - return strings.HasSuffix(host, ".tmprl.cloud") || strings.HasSuffix(host, ".api.temporal.io") -} - func isLoopbackHost(host string) bool { if host == "localhost" { return true @@ -180,13 +116,3 @@ func isLoopbackHost(host string) bool { ip := net.ParseIP(host) return ip != nil && ip.IsLoopback() } - -func indentLines(s, indent string) string { - lines := strings.Split(s, "\n") - for i, line := range lines { - if line != "" { - lines[i] = indent + line - } - } - return strings.Join(lines, "\n") -} diff --git a/internal/temporalcli/terminal.go b/internal/temporalcli/terminal.go index cb0ecaf4f..d122de4ed 100644 --- a/internal/temporalcli/terminal.go +++ b/internal/temporalcli/terminal.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "runtime" + "sort" "strings" "unicode" ) @@ -24,6 +25,8 @@ type checkOutcome int const ( checkSucceeded checkOutcome = iota checkFailed + checkInconclusive + checkSkipped ) type safeField struct { @@ -75,34 +78,6 @@ type terminalOptions struct { DisplayErr error } -type commandErrorRecorder struct { - err error -} - -func (r *commandErrorRecorder) Record(err error) { - if r.err == nil { - r.err = err - } - panic(recordedCommandError{}) -} - -func (r *commandErrorRecorder) Err() error { return r.err } - -type recordedCommandError struct{} - -func runRecordedCommand(run func()) (panicked bool) { - defer func() { - if recovered := recover(); recovered != nil { - if _, ok := recovered.(recordedCommandError); !ok { - panic(recovered) - } - panicked = true - } - }() - run() - return false -} - func handleTerminalError(commandErr error, options terminalOptions) Result { result := Result{CommandErr: commandErr, ExitStatus: defaultFailureExitStatus} displayErr := options.DisplayErr @@ -128,11 +103,11 @@ func handleTerminalError(commandErr error, options terminalOptions) Result { func normalizeError(err error) errorReport { var connectionErr *connectError if errors.As(err, &connectionErr) { - return connectionErr.report() + return connectionErrorReport(connectionErr) } var activityErr *activityNotFoundError if errors.As(err, &activityErr) { - return activityErr.report() + return activityNotFoundErrorReport(activityErr) } if err == nil || err.Error() == "" { return errorReport{Summary: "unknown error"} @@ -140,23 +115,77 @@ func normalizeError(err error) errorReport { return errorReport{Summary: err.Error()} } +// activityNotFoundErrorReport is the concrete terminal-owned adapter for a +// missing standalone Activity. The typed error retains identity and cause; +// this adapter preserves the deliberately conservative visible message. +func activityNotFoundErrorReport(_ *activityNotFoundError) errorReport { + return errorReport{Summary: "standalone Activity not found"} +} + +// connectionErrorReport is the concrete terminal-owned adapter for connection +// failures. connectError carries facts and never owns presentation layout. +func connectionErrorReport(err *connectError) errorReport { + report := errorReport{Summary: err.Error(), Action: suggestAction(&err.diagnosis, err.meta)} + if err.meta.Namespace != "" { + report.Context = append(report.Context, safeField{Label: "Namespace", Value: err.meta.Namespace}) + } + if err.meta.Address != "" { + report.Context = append(report.Context, safeField{Label: "TLS", Value: fmt.Sprintf("%t", err.meta.TLSConfigured)}) + } + if len(err.diagnosis.Stages) > 0 { + report.CheckHeading = "Connection checks" + if err.meta.Address != "" { + report.CheckHeading += " for " + err.meta.Address + } + } + for _, stage := range err.diagnosis.Stages { + outcome := checkInconclusive + switch stage.Status { + case diagOK: + outcome = checkSucceeded + case diagFail: + outcome = checkFailed + case diagSkipped: + outcome = checkSkipped + } + report.Checks = append(report.Checks, errorCheck{Outcome: outcome, Message: stage.Label}) + } + return report +} + func redactReport(report errorReport, secrets []string) errorReport { // This is defense in depth for exact values already known to the runtime, // not a claim that arbitrary legacy error prose is generically sanitized. // Structured adapters remain responsible for admitting only safe fields. + report = cloneErrorReport(report) + orderedSecrets := append([]string(nil), secrets...) + sort.Slice(orderedSecrets, func(i, j int) bool { + if len(orderedSecrets[i]) == len(orderedSecrets[j]) { + return orderedSecrets[i] < orderedSecrets[j] + } + return len(orderedSecrets[i]) > len(orderedSecrets[j]) + }) redact := func(value string) string { - for _, secret := range secrets { + for _, secret := range orderedSecrets { if secret != "" { value = strings.ReplaceAll(value, secret, "[REDACTED]") } } - return value + return escapeTerminalControls(value) } report.Summary = redact(report.Summary) - report.Usage = redact(report.Usage) + // Usage is generated by Cobra and intentionally contains formatting + // newlines. It receives exact-value redaction but not control escaping. + for _, secret := range orderedSecrets { + if secret != "" { + report.Usage = strings.ReplaceAll(report.Usage, secret, "[REDACTED]") + } + } for i := range report.Context { + report.Context[i].Label = redact(report.Context[i].Label) report.Context[i].Value = redact(report.Context[i].Value) } + report.CheckHeading = redact(report.CheckHeading) for i := range report.Checks { report.Checks[i].Message = redact(report.Checks[i].Message) } @@ -175,6 +204,44 @@ func redactReport(report errorReport, secrets []string) errorReport { return report } +func cloneErrorReport(report errorReport) errorReport { + clone := report + clone.Context = append([]safeField(nil), report.Context...) + clone.Checks = append([]errorCheck(nil), report.Checks...) + if report.Action == nil { + return clone + } + action := *report.Action + action.Invocations = append([]displayInvocation(nil), report.Action.Invocations...) + for i := range action.Invocations { + action.Invocations[i].Command = append([]string(nil), action.Invocations[i].Command...) + action.Invocations[i].Args = append([]string(nil), action.Invocations[i].Args...) + } + clone.Action = &action + return clone +} + +func escapeTerminalControls(value string) string { + var b strings.Builder + for _, r := range value { + if !unicode.IsControl(r) { + b.WriteRune(r) + continue + } + switch r { + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + fmt.Fprintf(&b, `\u{%x}`, r) + } + } + return b.String() +} + // renderErrorText is total over errorReport and performs no I/O or global // color lookup. Reports are redacted before reaching this function. func renderErrorText(report errorReport, options renderOptions) []byte { @@ -200,6 +267,12 @@ func renderErrorText(report errorReport, options renderOptions) []byte { if check.Outcome == checkFailed { symbol = "✗" colorCode = "31" + } else if check.Outcome == checkInconclusive { + symbol = "?" + colorCode = "33" + } else if check.Outcome == checkSkipped { + symbol = "-" + colorCode = "33" } if options.Color { symbol = "\x1b[" + colorCode + "m" + symbol + "\x1b[0m" @@ -241,20 +314,18 @@ func renderInvocation(invocation displayInvocation, shell displayShell) (string, return "", false } for i := range parts { - if containsControl(parts[i]) { - return "", false - } + parts[i] = escapeTerminalControls(parts[i]) if shell == displayShellPowerShell { parts[i] = quotePowerShell(parts[i]) } else { parts[i] = quotePOSIX(parts[i]) } } - return strings.Join(parts, " "), true -} - -func containsControl(value string) bool { - return strings.IndexFunc(value, unicode.IsControl) >= 0 + rendered := strings.Join(parts, " ") + if shell == displayShellPowerShell { + rendered = "& " + rendered + } + return rendered, true } func quotePOSIX(value string) string { @@ -267,10 +338,15 @@ func quotePOSIX(value string) string { } func quotePowerShell(value string) string { - if value != "" && strings.IndexFunc(value, func(r rune) bool { - return !(unicode.IsLetter(r) || unicode.IsDigit(r) || strings.ContainsRune("_@%+=:,./-", r)) - }) < 0 { - return value - } return "'" + strings.ReplaceAll(value, "'", "''") + "'" } + +func indentLines(s, indent string) string { + lines := strings.Split(s, "\n") + for i, line := range lines { + if line != "" { + lines[i] = indent + line + } + } + return strings.Join(lines, "\n") +} diff --git a/internal/temporalcli/terminal_test.go b/internal/temporalcli/terminal_test.go index f9efe71a3..664f6c32c 100644 --- a/internal/temporalcli/terminal_test.go +++ b/internal/temporalcli/terminal_test.go @@ -4,17 +4,16 @@ import ( "bytes" "context" "errors" + "fmt" "io" "os" - "path/filepath" + "strings" "testing" "github.com/fatih/color" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/temporalio/cli/cliext" - "golang.org/x/oauth2" ) func TestHandleTerminalErrorWritesOneRedactedReportAndRetainsBothErrors(t *testing.T) { @@ -61,45 +60,35 @@ func TestConnectErrorCarriesSemanticFactsWithoutRenderedStateOrRawArguments(t *t Stages: []diagStage{{Status: diagFail, Label: "TCP connection refused"}}, } err := newConnectError(diagnosis, connectMeta{Address: diagnosis.Address}, cause) + diagnosis.Stages[0].Label = "mutated after construction" assert.Equal(t, "failed connecting to Temporal server at 127.0.0.1:7233: connection refused", err.Error()) assert.ErrorIs(t, err, cause) report := normalizeError(err) + assert.Equal(t, "TCP connection refused", report.Checks[0].Message, "connect error must copy diagnosis stages") require.NotNil(t, report.Action) require.Len(t, report.Action.Invocations, 1) assert.Equal(t, []string{"temporal", "server", "start-dev"}, report.Action.Invocations[0].Command) assert.NotContains(t, err.Error(), "\n") } -func TestRecorderSeamCapturesFirstLeafFailureWithoutChangingItsIdentity(t *testing.T) { - // Seam decision: generated Run callbacks already call Fail as their final - // operation. A command-scoped recorder therefore captures every leaf error - // without changing generated Run to RunE. RunE was rejected because Cobra - // skips post-run cleanup when RunE returns an error. - want := errors.New("leaf failure") - var recorder commandErrorRecorder - - assert.True(t, runRecordedCommand(func() { recorder.Record(want) })) +func TestNormalizeErrorOwnsActivityNotFoundPresentationAndPreservesUnknownActivityErrors(t *testing.T) { + notFound := &activityNotFoundError{ + activityID: "activity-id", + cause: errors.New("server detail"), + } + report := normalizeError(fmt.Errorf("poll activity: %w", notFound)) - assert.ErrorIs(t, recorder.Err(), want) -} + assert.Equal(t, "standalone Activity not found", report.Summary) + assert.Empty(t, report.Context) + assert.Empty(t, report.Checks) + assert.Nil(t, report.Action) -func TestRecorderSeamStopsExecutionAfterFailureAndRunsCleanup(t *testing.T) { - var recorder commandErrorRecorder - workedAfterFailure := false - cleanupRan := false - func() { - defer func() { cleanupRan = true }() - assert.True(t, runRecordedCommand(func() { - recorder.Record(errors.New("stop here")) - workedAfterFailure = true - })) - }() - assert.False(t, workedAfterFailure) - assert.True(t, cleanupRan) + unknown := errors.New("activity result unavailable") + assert.Equal(t, unknown.Error(), normalizeError(unknown).Summary) } -func TestRenderInvocationUsesExplicitShellQuotingAndRejectsControls(t *testing.T) { +func TestRenderInvocationUsesExplicitShellQuotingAndEscapesControls(t *testing.T) { invocation := displayInvocation{Command: []string{"temporal", "config", "set"}, Args: []string{"--value", "space and 'quote'", "--profile", "-prod"}} posix, ok := renderInvocation(invocation, displayShellPOSIX) require.True(t, ok) @@ -107,9 +96,43 @@ func TestRenderInvocationUsesExplicitShellQuotingAndRejectsControls(t *testing.T assert.Contains(t, posix, "--profile -prod") powerShell, ok := renderInvocation(invocation, displayShellPowerShell) require.True(t, ok) + assert.True(t, strings.HasPrefix(powerShell, "& 'temporal' ")) assert.Contains(t, powerShell, `'space and ''quote'''`) - _, ok = renderInvocation(displayInvocation{Command: []string{"temporal"}, Args: []string{"unsafe\nvalue"}}, displayShellPOSIX) - assert.False(t, ok) + assert.Contains(t, powerShell, `'--profile' '-prod'`) + escaped, ok := renderInvocation(displayInvocation{Command: []string{"temporal"}, Args: []string{"unsafe\nvalue"}}, displayShellPOSIX) + require.True(t, ok) + assert.Equal(t, `temporal 'unsafe\nvalue'`, escaped) +} + +func TestRedactReportDeepCopiesEscapesControlsAndUsesLongestSecretFirst(t *testing.T) { + report := errorReport{ + Summary: "token-long\x1b[31m", + Context: []safeField{{Label: "Target\nName", Value: "token-long"}}, + CheckHeading: "Check\rHeading", + Checks: []errorCheck{{Outcome: checkFailed, Message: "token\ncheck"}}, + Action: &displayAction{ + Label: "use\ttoken-long", + Invocations: []displayInvocation{{Command: []string{"temporal"}, Args: []string{"token-long"}}}, + }, + } + secrets := []string{"token", "token-long"} + got := redactReport(report, secrets) + + assert.Equal(t, `[REDACTED]\u{1b}[31m`, got.Summary) + assert.Equal(t, `Target\nName`, got.Context[0].Label) + assert.Equal(t, `Check\rHeading`, got.CheckHeading) + assert.Equal(t, `[REDACTED]\ncheck`, got.Checks[0].Message) + assert.Equal(t, `use\t[REDACTED]`, got.Action.Label) + assert.Equal(t, "token-long", report.Context[0].Value, "source context must not be mutated") + assert.Equal(t, "token-long", report.Action.Invocations[0].Args[0], "nested source slices must not be mutated") + assert.Equal(t, "[REDACTED]", got.Action.Invocations[0].Args[0]) + assert.Equal(t, []string{"token", "token-long"}, secrets, "sorting must not mutate the caller's secret list") +} + +func TestRenderInvocationPowerShellDoublesSingleQuotesWithinOneQuotedToken(t *testing.T) { + got, ok := renderInvocation(displayInvocation{Command: []string{"temporal"}, Args: []string{"it's one token"}}, displayShellPowerShell) + require.True(t, ok) + assert.Equal(t, "& 'temporal' 'it''s one token'", got) } func TestFinishCommandPreservesOriginalErrorWhilePresentingCancellationCause(t *testing.T) { @@ -124,14 +147,17 @@ func TestFinishCommandPreservesOriginalErrorWhilePresentingCancellationCause(t * assert.NotContains(t, stderr.String(), original.Error()) } -func TestRecorderSeamGeneratedLeavesStopAfterRecording(t *testing.T) { +func TestGeneratedLeavesReturnErrorsThroughRunE(t *testing.T) { source, err := os.ReadFile("commands.gen.go") require.NoError(t, err) - allCalls := bytes.Count(source, []byte("cctx.Options.Fail(err)")) - terminalCalls := bytes.Count(source, []byte("cctx.Options.Fail(err)\n\t\t}")) - - assert.Greater(t, allCalls, 100, "expected to inspect every generated command leaf") - assert.Equal(t, allCalls, terminalCalls, "every generated Fail call must be the final operation in its branch") + runECalls := bytes.Count(source, []byte("s.Command.RunE = func")) + returnCalls := bytes.Count(source, []byte("return s.run(cctx, args)")) + markerCalls := bytes.Count(source, []byte("cctx.commandRunStarted = true")) + + assert.Greater(t, runECalls, 100, "expected to inspect every generated command leaf") + assert.Equal(t, runECalls, returnCalls, "every generated RunE must return its run error") + assert.Equal(t, runECalls, markerCalls, "every generated RunE must mark command execution") + assert.NotContains(t, string(source), "Options.Fail") } func TestExecuteRestoresColorAcrossFailureSources(t *testing.T) { @@ -149,7 +175,7 @@ func TestExecuteRestoresColorAcrossFailureSources(t *testing.T) { result := Execute(t.Context(), CommandOptions{ Args: args, IOStreams: IOStreams{Stdout: &stdout, Stderr: &stderr}, - DeprecatedEnvConfig: DeprecatedEnvConfig{DisableEnvConfig: true}, + DeprecatedEnvConfig: DeprecatedEnvConfig{DisableEnvConfig: true, EnvConfigName: "default"}, }) assert.Error(t, result.CommandErr) assert.True(t, color.NoColor, "global color policy must be restored") @@ -162,6 +188,61 @@ func TestEarlyJSONPolicyDisablesTerminalColor(t *testing.T) { assert.False(t, cctx.terminalColorEnabled()) } +func TestEarlyJSONFailuresRenderUsageWithoutANSI(t *testing.T) { + old := color.NoColor + color.NoColor = false + t.Cleanup(func() { color.NoColor = old }) + + for name, args := range map[string][]string{ + "json parse failure": {"workflow", "describe", "--output", "json", "--color", "always", "--not-a-flag"}, + "jsonl required failure": {"workflow", "describe", "--output=jsonl", "--color=always"}, + } { + t.Run(name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + result := Execute(t.Context(), CommandOptions{ + Args: args, + IOStreams: IOStreams{Stdout: &stdout, Stderr: &stderr}, + DeprecatedEnvConfig: DeprecatedEnvConfig{DisableEnvConfig: true, EnvConfigName: "default"}, + }) + + assert.Error(t, result.CommandErr) + assert.Equal(t, 1, result.ExitStatus) + assert.Empty(t, stdout.String()) + assert.Contains(t, stderr.String(), "Usage:") + assert.NotContains(t, stderr.String(), "\x1b[") + assert.Equal(t, 1, bytes.Count(stderr.Bytes(), []byte("Error:"))) + }) + } +} + +func TestExecuteRuntimeErrorPreservesIdentityWithoutUsageAndRetainsPresentationError(t *testing.T) { + configPath := t.TempDir() + writeErr := errors.New("stderr unavailable") + stderr := &countingErrorWriter{err: writeErr} + var stdout bytes.Buffer + + result := Execute(t.Context(), CommandOptions{ + Args: []string{ + "config", "get", + "--config-file", configPath, + "--disable-config-env", + }, + IOStreams: IOStreams{Stdout: &stdout, Stderr: stderr}, + DeprecatedEnvConfig: DeprecatedEnvConfig{DisableEnvConfig: true, EnvConfigName: "default"}, + }) + + require.Error(t, result.CommandErr) + var pathErr *os.PathError + require.ErrorAs(t, result.CommandErr, &pathErr) + assert.Equal(t, configPath, pathErr.Path) + assert.ErrorIs(t, result.CommandErr, pathErr) + assert.ErrorIs(t, result.PresentationErr, writeErr) + assert.Equal(t, 1, result.ExitStatus) + assert.Equal(t, 1, stderr.writes) + assert.NotContains(t, string(stderr.last), "Usage:") + assert.Empty(t, stdout.String()) +} + func TestUnknownFlagWithFollowingValueIsNotMisclassifiedAsUnknownCommand(t *testing.T) { var stdout, stderr bytes.Buffer result := Execute(t.Context(), CommandOptions{ @@ -201,49 +282,6 @@ func TestKnownSecretsIgnoresDisabledConfigEnvironment(t *testing.T) { assert.NotContains(t, cctx.knownSecrets(), "disabled-env-secret") } -func TestCaptureEffectiveConnectionSecretsIncludesProfileValues(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "temporal.toml") - require.NoError(t, os.WriteFile(configPath, []byte(`[profile.prod] -api_key = "profile-api-secret" -codec = { endpoint = "https://codec.example", auth = "profile-codec-secret" } -grpc_meta = { authorization = "profile-header-secret" } -tls = { client_cert_data = "profile-cert-secret", client_key_data = "profile-key-secret", server_ca_cert_data = "profile-ca-secret" } -`), 0o600)) - cctx := &CommandContext{ - Options: CommandOptions{EnvLookup: testEnvLookup{}}, - RootCommand: &TemporalCommand{CommonOptions: cliext.CommonOptions{ - ConfigFile: configPath, Profile: "prod", DisableConfigEnv: true, - }}, - } - captureEffectiveConnectionSecrets(cctx, &cliext.ClientOptions{}) - for _, secret := range []string{"profile-api-secret", "profile-codec-secret", "profile-header-secret", "profile-cert-secret", "profile-key-secret", "profile-ca-secret"} { - assert.Contains(t, cctx.knownSecrets(), secret) - } - assert.True(t, cctx.hasAPIKey) -} - -func TestCaptureEffectiveConnectionSecretsIncludesOAuthValues(t *testing.T) { - configPath := filepath.Join(t.TempDir(), "temporal.toml") - require.NoError(t, cliext.StoreClientOAuth(cliext.StoreClientOAuthOptions{ - ConfigFilePath: configPath, - ProfileName: "prod", - OAuth: &cliext.OAuthConfig{ - ClientConfig: &oauth2.Config{ClientID: "client", ClientSecret: "oauth-client-secret"}, - Token: &oauth2.Token{AccessToken: "oauth-access-secret", RefreshToken: "oauth-refresh-secret"}, - }, - })) - cctx := &CommandContext{ - Options: CommandOptions{EnvLookup: testEnvLookup{}}, - RootCommand: &TemporalCommand{CommonOptions: cliext.CommonOptions{ - ConfigFile: configPath, Profile: "prod", DisableConfigEnv: true, - }}, - } - captureEffectiveConnectionSecrets(cctx, &cliext.ClientOptions{}) - assert.ElementsMatch(t, []string{"oauth-client-secret", "oauth-access-secret", "oauth-refresh-secret"}, cctx.knownSecrets()) - assert.True(t, cctx.hasOAuth) - assert.False(t, cctx.hasAPIKey) -} - type testEnvLookup map[string]string func (e testEnvLookup) LookupEnv(name string) (string, bool) { From 47410b7ffac5ee9a77771cd3ff681211a3a4fefa Mon Sep 17 00:00:00 2001 From: Ross Nelson Date: Fri, 24 Jul 2026 11:49:31 -0400 Subject: [PATCH 7/9] Narrow error handling to connection failures --- CONTRIBUTING.md | 4 - cmd/temporal/main.go | 4 +- internal/commandsgen/code.go | 7 +- internal/commandsgen/code_test.go | 46 - internal/temporalcli/client_test.go | 34 +- internal/temporalcli/commands.activity.go | 17 +- .../commands.activity_internal_test.go | 26 - .../temporalcli/commands.activity_test.go | 7 +- internal/temporalcli/commands.extension.go | 6 +- .../temporalcli/commands.extension_test.go | 18 +- internal/temporalcli/commands.gen.go | 868 ++++++++++-------- internal/temporalcli/commands.go | 237 +---- .../temporalcli/commands.schedule_test.go | 3 +- internal/temporalcli/commands_test.go | 53 +- internal/temporalcli/connectdiag_test.go | 4 +- internal/temporalcli/terminal.go | 231 ++--- internal/temporalcli/terminal_test.go | 314 ++----- 17 files changed, 701 insertions(+), 1178 deletions(-) delete mode 100644 internal/commandsgen/code_test.go delete mode 100644 internal/temporalcli/commands.activity_internal_test.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ee7d75a77..9eeea5b26 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,10 +12,6 @@ Uses normal `go test`, e.g.: go test ./... -The root command does not enter the nested `cliext` module. Run `(cd cliext && go test ./...)` separately. - -The current transition branch has a recorded standalone `cliext` compile mismatch. Until that mismatch is fixed, the nested command must match the baseline exception. Do not report it as green. - See other tests for how to leverage things like the command harness and dev server suite. Example to run a single test case: diff --git a/cmd/temporal/main.go b/cmd/temporal/main.go index 8c14f2f83..0c482a6ed 100644 --- a/cmd/temporal/main.go +++ b/cmd/temporal/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "os" "github.com/temporalio/cli/internal/temporalcli" @@ -16,6 +15,5 @@ import ( func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() - result := temporalcli.Execute(ctx, temporalcli.CommandOptions{}) - os.Exit(result.ExitStatus) + temporalcli.Execute(ctx, temporalcli.CommandOptions{}) } diff --git a/internal/commandsgen/code.go b/internal/commandsgen/code.go index 5dee37a0c..84cff3bbc 100644 --- a/internal/commandsgen/code.go +++ b/internal/commandsgen/code.go @@ -302,9 +302,10 @@ func (c *Command) writeCode(w *codeWriter) error { } // If there are no subcommands, or if subcommands are optional, we need a run function if len(subCommands) == 0 || c.SubcommandsOptional { - w.writeLinef("s.Command.RunE = func(c *%v.Command, args []string) error {", w.importCobra()) - w.writeLinef("cctx.commandRunStarted = true") - w.writeLinef("return s.run(cctx, args)") + w.writeLinef("s.Command.Run = func(c *%v.Command, args []string) {", w.importCobra()) + w.writeLinef("if err := s.run(cctx, args); err != nil {") + w.writeLinef("cctx.Options.Fail(err)") + w.writeLinef("}") w.writeLinef("}") } // Init diff --git a/internal/commandsgen/code_test.go b/internal/commandsgen/code_test.go deleted file mode 100644 index 050d922d1..000000000 --- a/internal/commandsgen/code_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package commandsgen - -import ( - "bytes" - "testing" -) - -func TestGenerateRunnableCommandReturnsRunError(t *testing.T) { - generated, err := GenerateCommandsCode("temporalcli", "*CommandContext", Commands{ - CommandList: []Command{ - { - FullName: "temporal", - NamePath: []string{"temporal"}, - Summary: "Temporal commands", - DescriptionPlain: "Temporal commands", - }, - { - FullName: "temporal example", - NamePath: []string{"temporal", "example"}, - Summary: "Example command", - DescriptionPlain: "Example command", - }, - }, - }) - if err != nil { - t.Fatalf("GenerateCommandsCode() error = %v", err) - } - - for _, want := range [][]byte{ - []byte("s.Command.RunE = func(c *cobra.Command, args []string) error"), - []byte("cctx.commandRunStarted = true"), - []byte("return s.run(cctx, args)"), - } { - if !bytes.Contains(generated, want) { - t.Errorf("generated code does not contain %q", want) - } - } - for _, unwanted := range [][]byte{ - []byte("Options.Fail"), - []byte("s.Command.Run = func"), - } { - if bytes.Contains(generated, unwanted) { - t.Errorf("generated code unexpectedly contains %q", unwanted) - } - } -} diff --git a/internal/temporalcli/client_test.go b/internal/temporalcli/client_test.go index ae61176a1..5263e0799 100644 --- a/internal/temporalcli/client_test.go +++ b/internal/temporalcli/client_test.go @@ -124,12 +124,9 @@ func TestConnectDiagnosis_MTLSRequired(t *testing.T) { ) require.Error(t, res.Err) - msg := res.Stderr.String() - assert.Contains(t, msg, "Connection checks for "+addr) - assert.Contains(t, msg, "✓ TCP connection established") - assert.Contains(t, msg, "✗ TLS handshake failed: server requires mTLS") - assert.Contains(t, msg, "--tls-cert-path") - assert.Contains(t, msg, "--tls-key-path") + assert.Contains(t, res.Err.Error(), "failed connecting to Temporal server at "+addr) + assert.Contains(t, res.Err.Error(), "server requires client certificate (mTLS)") + assert.Empty(t, res.Stderr.String(), "the test harness overrides Fail and captures the error") } func TestConnectDiagnosis_Refused(t *testing.T) { @@ -147,10 +144,9 @@ func TestConnectDiagnosis_Refused(t *testing.T) { ) require.Error(t, res.Err) - msg := res.Stderr.String() - assert.Contains(t, msg, "connection refused") - assert.Contains(t, msg, "✗ TCP connection refused") - assert.Contains(t, msg, "Nothing is listening at "+addr) + assert.Contains(t, res.Err.Error(), "failed connecting to Temporal server at "+addr) + assert.Contains(t, res.Err.Error(), "connection refused") + assert.Empty(t, res.Stderr.String(), "the test harness overrides Fail and captures the error") } func TestConnectDiagnosis_DNSFailure(t *testing.T) { @@ -163,10 +159,9 @@ func TestConnectDiagnosis_DNSFailure(t *testing.T) { ) require.Error(t, res.Err) - msg := res.Stderr.String() - assert.Contains(t, msg, "could not resolve host") - assert.Contains(t, msg, "✗ DNS lookup") - assert.Contains(t, msg, `Could not resolve "does-not-exist.invalid"`) + assert.Contains(t, res.Err.Error(), "failed connecting to Temporal server at does-not-exist.invalid:7233") + assert.Contains(t, res.Err.Error(), "could not resolve host") + assert.Empty(t, res.Stderr.String(), "the test harness overrides Fail and captures the error") } func TestConnectDiagnosis_JSONOutputHasNoANSI(t *testing.T) { @@ -184,7 +179,8 @@ func TestConnectDiagnosis_JSONOutputHasNoANSI(t *testing.T) { ) require.Error(t, res.Err) - assert.NotContains(t, res.Stderr.String(), "\x1b[", "diagnosis must not contain ANSI escapes in JSON mode") + assert.NotContains(t, res.Err.Error(), "\x1b[", "connection errors must remain uncolored") + assert.Empty(t, res.Stderr.String(), "the test harness overrides Fail and captures the error") assert.Empty(t, res.Stdout.String(), "connection failures must not write to stdout") } @@ -203,7 +199,7 @@ func TestConnectDiagnosis_Disabled(t *testing.T) { ) require.Error(t, res.Err) - msg := res.Stderr.String() + msg := res.Err.Error() assert.Contains(t, msg, "failed connecting to Temporal server at "+addr) assert.NotContains(t, msg, "✗", "diagnosis must be suppressed when disabled") assert.NotContains(t, msg, "Namespace:") @@ -223,12 +219,12 @@ func TestConnectDiagnosis_CertFileMissing(t *testing.T) { require.Error(t, res.Err) var pathErr *os.PathError require.ErrorAs(t, res.Err, &pathErr) - msg := res.Stderr.String() + msg := res.Err.Error() assert.Contains(t, msg, "failed preparing Temporal server connection") assert.NotContains(t, msg, "server connection at", "Build returned no authoritative effective address") assert.Contains(t, msg, "cannot read file") assert.Contains(t, msg, "/definitely/does/not/exist") - assert.NotContains(t, msg, "✓", "no probe stages expected when the dial never happened") + assert.Empty(t, res.Stderr.String(), "the test harness overrides Fail and captures the error") } func TestConnectDiagnosis_ProfileAddressUsesEffectiveAddressWithoutGuessingProvenance(t *testing.T) { @@ -250,7 +246,7 @@ address = "does-not-exist.invalid:7233" ) require.Error(t, res.Err) - msg := res.Stderr.String() + msg := res.Err.Error() assert.Contains(t, msg, "failed connecting to Temporal server at does-not-exist.invalid:7233") assert.NotContains(t, msg, "config profile") assert.NotContains(t, msg, "temporal config get") diff --git a/internal/temporalcli/commands.activity.go b/internal/temporalcli/commands.activity.go index 2b860fa15..582f5d622 100644 --- a/internal/temporalcli/commands.activity.go +++ b/internal/temporalcli/commands.activity.go @@ -223,7 +223,7 @@ func getActivityResult(cctx *CommandContext, cl client.Client, namespace, activi if err != nil { var notFound *serviceerror.NotFound if errors.As(err, ¬Found) { - return &activityNotFoundError{activityID: activityID, cause: err} + return fmt.Errorf("activity not found: %s", activityID) } return fmt.Errorf("failed polling activity result: %w", err) } @@ -241,21 +241,6 @@ func getActivityResult(cctx *CommandContext, cl client.Client, namespace, activi } } -// activityNotFoundError retains the activity identity and the server's typed -// NotFound error. Terminal presentation is owned by terminal.go. -type activityNotFoundError struct { - activityID string - cause error -} - -func (e *activityNotFoundError) Error() string { - return fmt.Sprintf("activity not found: %s", e.activityID) -} - -func (e *activityNotFoundError) Unwrap() error { - return e.cause -} - // Matches the SDK's pollActivityTimeout in internal_activity_client.go. const pollActivityTimeout = 60 * time.Second diff --git a/internal/temporalcli/commands.activity_internal_test.go b/internal/temporalcli/commands.activity_internal_test.go deleted file mode 100644 index 861cb6851..000000000 --- a/internal/temporalcli/commands.activity_internal_test.go +++ /dev/null @@ -1,26 +0,0 @@ -package temporalcli - -import ( - "errors" - "fmt" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.temporal.io/api/serviceerror" -) - -func TestActivityNotFoundErrorPreservesIdentityAndCauseWithoutPresentationState(t *testing.T) { - cause, ok := serviceerror.NewNotFound("server detail").(*serviceerror.NotFound) - require.True(t, ok) - wrappedCause := fmt.Errorf("poll failed: %w", cause) - err := &activityNotFoundError{activityID: "activity-id", cause: wrappedCause} - - var notFound *serviceerror.NotFound - assert.ErrorAs(t, err, ¬Found) - assert.Same(t, cause, notFound) - assert.Equal(t, "activity not found: activity-id", err.Error()) - - assert.True(t, errors.Is(err, wrappedCause)) - assert.True(t, errors.Is(err, cause)) -} diff --git a/internal/temporalcli/commands.activity_test.go b/internal/temporalcli/commands.activity_test.go index e53ce6472..ec6b50a4f 100644 --- a/internal/temporalcli/commands.activity_test.go +++ b/internal/temporalcli/commands.activity_test.go @@ -819,12 +819,7 @@ func (s *SharedServerSuite) TestActivity_Result_NotFound() { ) s.Error(res.Err) s.Contains(res.Err.Error(), "not found") - var notFound *serviceerror.NotFound - s.ErrorAs(res.Err, ¬Found) - s.Equal(1, res.Runtime.ExitStatus) - s.Equal("Error: standalone Activity not found\n", res.Stderr.String()) - s.NotContains(res.Stderr.String(), "Try") - s.Empty(res.Stdout.String()) + s.NotContains(res.Stdout.String(), "FAILED") } func (s *SharedServerSuite) TestActivity_Describe() { diff --git a/internal/temporalcli/commands.extension.go b/internal/temporalcli/commands.extension.go index 2ac7a1df6..b00900810 100644 --- a/internal/temporalcli/commands.extension.go +++ b/internal/temporalcli/commands.extension.go @@ -25,13 +25,13 @@ var cliArgsToParseForExtension = map[string]bool{ "command-timeout": true, } -// ExtensionNonZeroExit preserves a started extension's exit status and marks -// its output as extension-owned, so the parent does not render another error. type ExtensionNonZeroExit struct { *exec.ExitError } -func (err ExtensionNonZeroExit) Unwrap() error { return err.ExitError } +func (err ExtensionNonZeroExit) Unwrap() error { + return err.ExitError +} // tryExecuteExtension tries to execute an extension command if the command is not a built-in command. // It returns an error if the extension command fails, and a boolean indicating whether an extension was executed. diff --git a/internal/temporalcli/commands.extension_test.go b/internal/temporalcli/commands.extension_test.go index 810215203..2590be0cc 100644 --- a/internal/temporalcli/commands.extension_test.go +++ b/internal/temporalcli/commands.extension_test.go @@ -233,28 +233,26 @@ func TestExtension_FailsOnNonExecutableCommand(t *testing.T) { h := newExtensionHarness(t) // Create file without execute permission. path := filepath.Join(h.binDir, "temporal-foo") - err := os.WriteFile(path, []byte("a text file"), 0644) + err := os.WriteFile(path, []byte("a text file"), 0o644) require.NoError(t, err) res := h.Execute("foo") - assert.Empty(t, res.Stdout.String()) - assert.Contains(t, res.Stderr.String(), "Usage:") + assert.Contains(t, res.Stdout.String(), "Usage:") // help text is shown assert.EqualError(t, res.Err, "unknown command") } func TestExtension_PassesThroughNonZeroExit(t *testing.T) { h := newExtensionHarness(t) - h.createExtension("temporal-foo", codeEchoArgs, codeEchoStderr("child error"), codeExit(42)) + h.createExtension("temporal-foo", codeEchoArgs, codeExit(42)) res := h.Execute("foo") assert.Equal(t, "Args: temporal-foo \n", res.Stdout.String()) - var extensionErr temporalcli.ExtensionNonZeroExit - assert.ErrorAs(t, res.Err, &extensionErr) - assert.Equal(t, 42, res.Runtime.ExitStatus) - assert.Equal(t, "child error\n", res.Stderr.String(), "extension owns its stderr without a parent report") - assert.NotContains(t, res.Stderr.String(), "Error:") + var exitError temporalcli.ExtensionNonZeroExit + if assert.ErrorAs(t, res.Err, &exitError) { + assert.Equal(t, 42, exitError.ExitCode()) + } } func TestExtension_FailsOnCommandTimeout(t *testing.T) { @@ -312,7 +310,7 @@ func (h *extensionHarness) createExtension(name string, code ...string) string { // Write source file. srcPath := filepath.Join(h.binDir, name+".go") - require.NoError(h.t, os.WriteFile(srcPath, formatted, 0644)) + require.NoError(h.t, os.WriteFile(srcPath, formatted, 0o644)) // Build executable. binPath := filepath.Join(h.binDir, name) diff --git a/internal/temporalcli/commands.gen.go b/internal/temporalcli/commands.gen.go index bb91fc3b5..ce21f9d26 100644 --- a/internal/temporalcli/commands.gen.go +++ b/internal/temporalcli/commands.gen.go @@ -577,9 +577,10 @@ func NewTemporalActivityCancelCommand(cctx *CommandContext, parent *TemporalActi s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for cancellation.") s.ActivityReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -611,9 +612,10 @@ func NewTemporalActivityCompleteCommand(cctx *CommandContext, parent *TemporalAc s.Command.Flags().StringVarP(&s.RunId, "run-id", "r", "", "Run ID. For workflow Activities (when --workflow-id is provided), this is the Workflow Run ID. For Standalone Activities, this is the Activity Run ID.") s.Command.Flags().StringVar(&s.Result, "result", "", "Result `JSON` to return. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "result") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -637,9 +639,10 @@ func NewTemporalActivityCountCommand(cctx *CommandContext, parent *TemporalActiv } s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Query to filter Activity Executions to count.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -665,9 +668,10 @@ func NewTemporalActivityDescribeCommand(cctx *CommandContext, parent *TemporalAc s.Command.Args = cobra.NoArgs s.Command.Flags().BoolVar(&s.Raw, "raw", false, "Print properties without changing their format.") s.ActivityReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -693,9 +697,10 @@ func NewTemporalActivityExecuteCommand(cctx *CommandContext, parent *TemporalAct s.Command.Args = cobra.NoArgs s.ActivityStartOptions.BuildFlags(s.Command.Flags()) s.PayloadInputOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -728,9 +733,10 @@ func NewTemporalActivityFailCommand(cctx *CommandContext, parent *TemporalActivi s.Command.Flags().StringVarP(&s.RunId, "run-id", "r", "", "Run ID. For workflow Activities (when --workflow-id is provided), this is the Workflow Run ID. For Standalone Activities, this is the Activity Run ID.") s.Command.Flags().StringVar(&s.Detail, "detail", "", "Failure detail (JSON). Attached as the failure details payload.") s.Command.Flags().StringVar(&s.Reason, "reason", "", "Failure reason. Attached as the failure message.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -758,9 +764,10 @@ func NewTemporalActivityListCommand(cctx *CommandContext, parent *TemporalActivi s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Query to filter the Activity Executions to list.") s.Command.Flags().IntVar(&s.Limit, "limit", 0, "Maximum number of Activity Executions to display.") s.Command.Flags().IntVar(&s.PageSize, "page-size", 0, "Maximum number of Activity Executions to fetch at a time from the server.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -790,9 +797,10 @@ func NewTemporalActivityPauseCommand(cctx *CommandContext, parent *TemporalActiv s.Command.Flags().StringVar(&s.Identity, "identity", "", "The identity of the user or client submitting this request.") s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for pausing the Activity.") s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -829,9 +837,10 @@ func NewTemporalActivityResetCommand(cctx *CommandContext, parent *TemporalActiv s.Command.Flags().Var(&s.Jitter, "jitter", "The activity will reset at random a time within the specified duration. Can only be used with --query.") s.Command.Flags().BoolVar(&s.RestoreOriginalOptions, "restore-original-options", false, "Restore the original options of the activity.") s.SingleActivityOrBatchOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -855,9 +864,10 @@ func NewTemporalActivityResultCommand(cctx *CommandContext, parent *TemporalActi } s.Command.Args = cobra.NoArgs s.ActivityReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -883,9 +893,10 @@ func NewTemporalActivityStartCommand(cctx *CommandContext, parent *TemporalActiv s.Command.Args = cobra.NoArgs s.ActivityStartOptions.BuildFlags(s.Command.Flags()) s.PayloadInputOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -911,9 +922,10 @@ func NewTemporalActivityTerminateCommand(cctx *CommandContext, parent *TemporalA s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for termination. Defaults to a message with the current user's name.") s.ActivityReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -946,9 +958,10 @@ func NewTemporalActivityUnpauseCommand(cctx *CommandContext, parent *TemporalAct s.Jitter = 0 s.Command.Flags().Var(&s.Jitter, "jitter", "The activity will start at random a time within the specified duration. Can only be used with --query.") s.SingleActivityOrBatchOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1000,9 +1013,10 @@ func NewTemporalActivityUpdateOptionsCommand(cctx *CommandContext, parent *Tempo s.Command.Flags().IntVar(&s.RetryMaximumAttempts, "retry-maximum-attempts", 0, "Maximum number of attempts. When exceeded the retries stop even if not expired yet. Setting this value to 1 disables retries. Setting this value to 0 means unlimited attempts(up to the timeouts).") s.Command.Flags().BoolVar(&s.RestoreOriginalOptions, "restore-original-options", false, "Restore the original options of the activity.") s.SingleActivityOrBatchOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1051,9 +1065,10 @@ func NewTemporalBatchDescribeCommand(cctx *CommandContext, parent *TemporalBatch s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.JobId, "job-id", "", "Batch job ID. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "job-id") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1077,9 +1092,10 @@ func NewTemporalBatchListCommand(cctx *CommandContext, parent *TemporalBatchComm } s.Command.Args = cobra.NoArgs s.Command.Flags().IntVar(&s.Limit, "limit", 0, "Maximum number of batch jobs to display.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1107,9 +1123,10 @@ func NewTemporalBatchTerminateCommand(cctx *CommandContext, parent *TemporalBatc _ = cobra.MarkFlagRequired(s.Command.Flags(), "job-id") s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for terminating the batch job. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "reason") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1158,9 +1175,10 @@ func NewTemporalConfigDeleteCommand(cctx *CommandContext, parent *TemporalConfig s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Prop, "prop", "p", "", "Specific property to delete. If unset, deletes entire profile. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "prop") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1182,9 +1200,10 @@ func NewTemporalConfigDeleteProfileCommand(cctx *CommandContext, parent *Tempora s.Command.Long = "Remove a full profile entirely. The `--profile` must be set explicitly.\n\n```\ntemporal config delete-profile \\\n --profile my-profile\n```" } s.Command.Args = cobra.NoArgs - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1208,9 +1227,10 @@ func NewTemporalConfigGetCommand(cctx *CommandContext, parent *TemporalConfigCom } s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Prop, "prop", "p", "", "Specific property to get.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1232,9 +1252,10 @@ func NewTemporalConfigListCommand(cctx *CommandContext, parent *TemporalConfigCo s.Command.Long = "List profile names in the config file.\n\n```\ntemporal config list\n```" } s.Command.Args = cobra.NoArgs - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1262,9 +1283,10 @@ func NewTemporalConfigSetCommand(cctx *CommandContext, parent *TemporalConfigCom _ = cobra.MarkFlagRequired(s.Command.Flags(), "prop") s.Command.Flags().StringVarP(&s.Value, "value", "v", "", "Property value. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "value") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1311,9 +1333,10 @@ func NewTemporalEnvDeleteCommand(cctx *CommandContext, parent *TemporalEnvComman } s.Command.Args = cobra.MaximumNArgs(1) s.Command.Flags().StringVarP(&s.Key, "key", "k", "", "Property name.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1337,9 +1360,10 @@ func NewTemporalEnvGetCommand(cctx *CommandContext, parent *TemporalEnvCommand) } s.Command.Args = cobra.MaximumNArgs(1) s.Command.Flags().StringVarP(&s.Key, "key", "k", "", "Property name.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1359,9 +1383,10 @@ func NewTemporalEnvListCommand(cctx *CommandContext, parent *TemporalEnvCommand) s.Command.Args = cobra.NoArgs s.Command.Annotations = make(map[string]string) s.Command.Annotations["ignoresMissingEnv"] = "true" - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1389,9 +1414,10 @@ func NewTemporalEnvSetCommand(cctx *CommandContext, parent *TemporalEnvCommand) s.Command.Annotations["ignoresMissingEnv"] = "true" s.Command.Flags().StringVarP(&s.Key, "key", "k", "", "Property name (required).") s.Command.Flags().StringVarP(&s.Value, "value", "v", "", "Property value (required).") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1466,9 +1492,10 @@ func NewTemporalNexusOperationCancelCommand(cctx *CommandContext, parent *Tempor s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for cancellation.") s.NexusOperationReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1492,9 +1519,10 @@ func NewTemporalNexusOperationCountCommand(cctx *CommandContext, parent *Tempora } s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Query to filter Nexus Operation Executions to count.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1520,9 +1548,10 @@ func NewTemporalNexusOperationDescribeCommand(cctx *CommandContext, parent *Temp s.Command.Args = cobra.NoArgs s.Command.Flags().BoolVar(&s.Raw, "raw", false, "Print properties without changing their format.") s.NexusOperationReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1548,9 +1577,10 @@ func NewTemporalNexusOperationExecuteCommand(cctx *CommandContext, parent *Tempo s.Command.Args = cobra.NoArgs s.NexusOperationStartOptions.BuildFlags(s.Command.Flags()) s.PayloadInputOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1578,9 +1608,10 @@ func NewTemporalNexusOperationListCommand(cctx *CommandContext, parent *Temporal s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Query to filter the Nexus Operation Executions to list.") s.Command.Flags().IntVar(&s.Limit, "limit", 0, "Maximum number of Nexus Operation Executions to display.") s.Command.Flags().IntVar(&s.PageSize, "page-size", 0, "Maximum number of Nexus Operation Executions to fetch at a time from the server.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1604,9 +1635,10 @@ func NewTemporalNexusOperationResultCommand(cctx *CommandContext, parent *Tempor } s.Command.Args = cobra.NoArgs s.NexusOperationReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1632,9 +1664,10 @@ func NewTemporalNexusOperationStartCommand(cctx *CommandContext, parent *Tempora s.Command.Args = cobra.NoArgs s.NexusOperationStartOptions.BuildFlags(s.Command.Flags()) s.PayloadInputOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1660,9 +1693,10 @@ func NewTemporalNexusOperationTerminateCommand(cctx *CommandContext, parent *Tem s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for termination. Defaults to a message with the current user's name.") s.NexusOperationReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1736,9 +1770,10 @@ func NewTemporalOperatorClusterDescribeCommand(cctx *CommandContext, parent *Tem } s.Command.Args = cobra.NoArgs s.Command.Flags().BoolVar(&s.Detail, "detail", false, "Show history shard count and Cluster/Service version information.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1760,9 +1795,10 @@ func NewTemporalOperatorClusterHealthCommand(cctx *CommandContext, parent *Tempo s.Command.Long = "View information about the health of a Temporal Service:\n\n```\ntemporal operator cluster health\n```" } s.Command.Args = cobra.NoArgs - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1786,9 +1822,10 @@ func NewTemporalOperatorClusterListCommand(cctx *CommandContext, parent *Tempora } s.Command.Args = cobra.NoArgs s.Command.Flags().IntVar(&s.Limit, "limit", 0, "Maximum number of Clusters to display.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1813,9 +1850,10 @@ func NewTemporalOperatorClusterRemoveCommand(cctx *CommandContext, parent *Tempo s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Name, "name", "", "Cluster/Service name. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "name") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1837,9 +1875,10 @@ func NewTemporalOperatorClusterSystemCommand(cctx *CommandContext, parent *Tempo s.Command.Long = "Show Temporal Server information for Temporal Clusters (Service): Server\nversion, scheduling support, and more. This information helps diagnose\nproblems with the Temporal Server.\n\nThe command defaults to the local Service. Otherwise, use the\n`--frontend-address` option to specify a Cluster (Service) endpoint:\n\n```\ntemporal operator cluster system \\\n --frontend-address \"YourRemoteEndpoint:YourRemotePort\"\n```" } s.Command.Args = cobra.NoArgs - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1868,9 +1907,10 @@ func NewTemporalOperatorClusterUpsertCommand(cctx *CommandContext, parent *Tempo _ = cobra.MarkFlagRequired(s.Command.Flags(), "frontend-address") s.Command.Flags().BoolVar(&s.EnableConnection, "enable-connection", false, "Set the connection to \"enabled\".") s.Command.Flags().BoolVar(&s.EnableReplication, "enable-replication", false, "Set the replication to \"enabled\".") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1941,9 +1981,10 @@ func NewTemporalOperatorNamespaceCreateCommand(cctx *CommandContext, parent *Tem s.VisibilityArchivalState = cliext.NewFlagStringEnum([]string{"disabled", "enabled"}, "disabled") s.Command.Flags().Var(&s.VisibilityArchivalState, "visibility-archival-state", "Visibility archival state. Accepted values: disabled, enabled.") s.Command.Flags().StringVar(&s.VisibilityUri, "visibility-uri", "", "Archive visibility data to this `URI`. Once enabled, can't be changed.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1967,9 +2008,10 @@ func NewTemporalOperatorNamespaceDeleteCommand(cctx *CommandContext, parent *Tem } s.Command.Args = cobra.MaximumNArgs(1) s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Request confirmation before deletion.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -1993,9 +2035,10 @@ func NewTemporalOperatorNamespaceDescribeCommand(cctx *CommandContext, parent *T } s.Command.Args = cobra.MaximumNArgs(1) s.Command.Flags().StringVar(&s.NamespaceId, "namespace-id", "", "Namespace ID.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2017,9 +2060,10 @@ func NewTemporalOperatorNamespaceListCommand(cctx *CommandContext, parent *Tempo s.Command.Long = "Display a detailed listing for all Namespaces on the Service:\n\n```\ntemporal operator namespace list\n```" } s.Command.Args = cobra.NoArgs - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2069,9 +2113,10 @@ func NewTemporalOperatorNamespaceUpdateCommand(cctx *CommandContext, parent *Tem s.VisibilityArchivalState = cliext.NewFlagStringEnum([]string{"disabled", "enabled"}, "") s.Command.Flags().Var(&s.VisibilityArchivalState, "visibility-archival-state", "Visibility archival state. Accepted values: disabled, enabled.") s.Command.Flags().StringVar(&s.VisibilityUri, "visibility-uri", "", "Archive visibility data to this `URI`. Once enabled, can't be changed.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2141,9 +2186,10 @@ func NewTemporalOperatorNexusEndpointCreateCommand(cctx *CommandContext, parent s.Command.Args = cobra.NoArgs s.NexusEndpointIdentityOptions.BuildFlags(s.Command.Flags()) s.NexusEndpointConfigOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2167,9 +2213,10 @@ func NewTemporalOperatorNexusEndpointDeleteCommand(cctx *CommandContext, parent } s.Command.Args = cobra.NoArgs s.NexusEndpointIdentityOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2193,9 +2240,10 @@ func NewTemporalOperatorNexusEndpointGetCommand(cctx *CommandContext, parent *Te } s.Command.Args = cobra.NoArgs s.NexusEndpointIdentityOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2217,9 +2265,10 @@ func NewTemporalOperatorNexusEndpointListCommand(cctx *CommandContext, parent *T s.Command.Long = "List all Nexus Endpoints on the Server.\n\n```\ntemporal operator nexus endpoint list\n```" } s.Command.Args = cobra.NoArgs - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2247,9 +2296,10 @@ func NewTemporalOperatorNexusEndpointUpdateCommand(cctx *CommandContext, parent s.Command.Flags().BoolVar(&s.UnsetDescription, "unset-description", false, "Unset the description.") s.NexusEndpointIdentityOptions.BuildFlags(s.Command.Flags()) s.NexusEndpointConfigOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2300,9 +2350,10 @@ func NewTemporalOperatorSearchAttributeCreateCommand(cctx *CommandContext, paren s.Type = cliext.NewFlagStringEnumArray([]string{"Text", "Keyword", "Int", "Double", "Bool", "Datetime", "KeywordList"}, []string{}) s.Command.Flags().Var(&s.Type, "type", "Search Attribute type. Accepted values: Text, Keyword, Int, Double, Bool, Datetime, KeywordList. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "type") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2324,9 +2375,10 @@ func NewTemporalOperatorSearchAttributeListCommand(cctx *CommandContext, parent s.Command.Long = "Display a list of active Search Attributes that can be assigned or used with\nWorkflow Queries. You can manage this list and add attributes as needed:\n\n```\ntemporal operator search-attribute list\n```" } s.Command.Args = cobra.NoArgs - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2353,9 +2405,10 @@ func NewTemporalOperatorSearchAttributeRemoveCommand(cctx *CommandContext, paren s.Command.Flags().StringArrayVar(&s.Name, "name", nil, "Search Attribute name. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "name") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm removal.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2417,9 +2470,10 @@ func NewTemporalScheduleBackfillCommand(cctx *CommandContext, parent *TemporalSc _ = cobra.MarkFlagRequired(s.Command.Flags(), "start-time") s.OverlapPolicyOptions.BuildFlags(s.Command.Flags()) s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2456,9 +2510,10 @@ func NewTemporalScheduleCreateCommand(cctx *CommandContext, parent *TemporalSche s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "name": "type", })) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2482,9 +2537,10 @@ func NewTemporalScheduleDeleteCommand(cctx *CommandContext, parent *TemporalSche } s.Command.Args = cobra.NoArgs s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2508,9 +2564,10 @@ func NewTemporalScheduleDescribeCommand(cctx *CommandContext, parent *TemporalSc } s.Command.Args = cobra.NoArgs s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2538,9 +2595,10 @@ func NewTemporalScheduleListCommand(cctx *CommandContext, parent *TemporalSchedu s.Command.Flags().BoolVarP(&s.Long, "long", "l", false, "Show detailed information.") s.Command.Flags().BoolVar(&s.ReallyLong, "really-long", false, "Show extensive information in non-table form.") s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Filter results using given List Filter.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2570,9 +2628,10 @@ func NewTemporalScheduleListMatchingTimesCommand(cctx *CommandContext, parent *T s.Command.Flags().Var(&s.EndTime, "end-time", "End of time range to list matching times. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "end-time") s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2602,9 +2661,10 @@ func NewTemporalScheduleToggleCommand(cctx *CommandContext, parent *TemporalSche s.Command.Flags().StringVar(&s.Reason, "reason", "(no reason provided)", "Reason for pausing or unpausing the Schedule.") s.Command.Flags().BoolVar(&s.Unpause, "unpause", false, "Unpause the Schedule.") s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2630,9 +2690,10 @@ func NewTemporalScheduleTriggerCommand(cctx *CommandContext, parent *TemporalSch s.Command.Args = cobra.NoArgs s.ScheduleIdOptions.BuildFlags(s.Command.Flags()) s.OverlapPolicyOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2667,9 +2728,10 @@ func NewTemporalScheduleUpdateCommand(cctx *CommandContext, parent *TemporalSche s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "name": "type", })) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2743,9 +2805,10 @@ func NewTemporalServerStartDevCommand(cctx *CommandContext, parent *TemporalServ s.Command.Flags().StringArrayVar(&s.DynamicConfigValue, "dynamic-config-value", nil, "Dynamic configuration value using `KEY=VALUE` pairs. Keys must be identifiers, and values must be JSON values. For example: `YourKey=\"YourString\"` Can be passed multiple times.") s.Command.Flags().BoolVar(&s.LogConfig, "log-config", false, "Print the server config to stderr.") s.Command.Flags().StringArrayVar(&s.SearchAttribute, "search-attribute", nil, "Search attributes to register using `KEY=VALUE` pairs. Keys must be identifiers, and values must be the search attribute type, which is one of the following: Text, Keyword, Int, Double, Bool, Datetime, KeywordList.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2823,9 +2886,10 @@ func NewTemporalTaskQueueConfigGetCommand(cctx *CommandContext, parent *Temporal s.TaskQueueType = cliext.NewFlagStringEnum([]string{"workflow", "activity", "nexus"}, "") s.Command.Flags().Var(&s.TaskQueueType, "task-queue-type", "Task Queue type. Accepted values: workflow, activity, nexus. Accepted values: workflow, activity, nexus. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "task-queue-type") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2868,9 +2932,10 @@ func NewTemporalTaskQueueConfigSetCommand(cctx *CommandContext, parent *Temporal s.Command.Flags().StringVar(&s.FairnessKeyRpsLimitReason, "fairness-key-rps-limit-reason", "", "Reason for fairness key rate limit update.") s.Command.Flags().StringArrayVar(&s.FairnessKeyWeight, "fairness-key-weight", nil, "Set or unset fairness key weight overrides in format key=weight or key=default. Use key=weight to set a positive weight value; use key=default to unset. Can be specified multiple times. Example: --fairness-key-weight HighPriority=2.0 --fairness-key-weight LowPriority=default.") s.Command.Flags().BoolVar(&s.FairnessKeyWeightClearAll, "fairness-key-weight-clear-all", false, "Unset all fairness key weight overrides. Cannot be used with --fairness-key-weight.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2917,9 +2982,10 @@ func NewTemporalTaskQueueDescribeCommand(cctx *CommandContext, parent *TemporalT s.Command.Flags().IntVar(&s.PartitionsLegacy, "partitions-legacy", 1, "Query partitions 1 through `N`. Experimental/Temporary feature. Legacy mode only.") s.Command.Flags().BoolVar(&s.DisableStats, "disable-stats", false, "Disable task queue statistics.") s.Command.Flags().BoolVar(&s.ReportConfig, "report-config", false, "Include task queue configuration in the response. When enabled, the command will return the current rate limit configuration for the task queue.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2950,9 +3016,10 @@ func NewTemporalTaskQueueGetBuildIdReachabilityCommand(cctx *CommandContext, par s.ReachabilityType = cliext.NewFlagStringEnum([]string{"open", "closed", "existing"}, "existing") s.Command.Flags().Var(&s.ReachabilityType, "reachability-type", "Reachability filter. `open`: reachable by one or more open workflows. `closed`: reachable by one or more closed workflows. `existing`: reachable by either. New Workflow Executions reachable by a Build ID are always reported. Accepted values: open, closed, existing.") s.Command.Flags().StringArrayVarP(&s.TaskQueue, "task-queue", "t", nil, "Search only the specified task queue(s). Can be passed multiple times.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -2981,9 +3048,10 @@ func NewTemporalTaskQueueGetBuildIdsCommand(cctx *CommandContext, parent *Tempor s.Command.Flags().StringVarP(&s.TaskQueue, "task-queue", "t", "", "Task Queue name. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "task-queue") s.Command.Flags().IntVar(&s.MaxSets, "max-sets", 0, "Max return count. Use 1 for default major version. Use 0 for all sets.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3008,9 +3076,10 @@ func NewTemporalTaskQueueListPartitionCommand(cctx *CommandContext, parent *Temp s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.TaskQueue, "task-queue", "t", "", "Task Queue name. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "task-queue") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3068,9 +3137,10 @@ func NewTemporalTaskQueueUpdateBuildIdsAddNewCompatibleCommand(cctx *CommandCont s.Command.Flags().StringVar(&s.ExistingCompatibleBuildId, "existing-compatible-build-id", "", "Pre-existing Build ID in this Task Queue. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "existing-compatible-build-id") s.Command.Flags().BoolVar(&s.SetAsDefault, "set-as-default", false, "Set the expanded Build ID set as the Task Queue default.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3100,9 +3170,10 @@ func NewTemporalTaskQueueUpdateBuildIdsAddNewDefaultCommand(cctx *CommandContext _ = cobra.MarkFlagRequired(s.Command.Flags(), "build-id") s.Command.Flags().StringVarP(&s.TaskQueue, "task-queue", "t", "", "Task Queue name. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "task-queue") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3132,9 +3203,10 @@ func NewTemporalTaskQueueUpdateBuildIdsPromoteIdInSetCommand(cctx *CommandContex _ = cobra.MarkFlagRequired(s.Command.Flags(), "build-id") s.Command.Flags().StringVarP(&s.TaskQueue, "task-queue", "t", "", "Task Queue name. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "task-queue") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3164,9 +3236,10 @@ func NewTemporalTaskQueueUpdateBuildIdsPromoteSetCommand(cctx *CommandContext, p _ = cobra.MarkFlagRequired(s.Command.Flags(), "build-id") s.Command.Flags().StringVarP(&s.TaskQueue, "task-queue", "t", "", "Task Queue name. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "task-queue") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3230,9 +3303,10 @@ func NewTemporalTaskQueueVersioningAddRedirectRuleCommand(cctx *CommandContext, s.Command.Flags().StringVar(&s.TargetBuildId, "target-build-id", "", "Target build ID. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "target-build-id") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3263,9 +3337,10 @@ func NewTemporalTaskQueueVersioningCommitBuildIdCommand(cctx *CommandContext, pa _ = cobra.MarkFlagRequired(s.Command.Flags(), "build-id") s.Command.Flags().BoolVar(&s.Force, "force", false, "Bypass recent-poller validation.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3296,9 +3371,10 @@ func NewTemporalTaskQueueVersioningDeleteAssignmentRuleCommand(cctx *CommandCont _ = cobra.MarkFlagRequired(s.Command.Flags(), "rule-index") s.Command.Flags().BoolVar(&s.Force, "force", false, "Bypass one-unconditional-rule validation.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3327,9 +3403,10 @@ func NewTemporalTaskQueueVersioningDeleteRedirectRuleCommand(cctx *CommandContex s.Command.Flags().StringVar(&s.SourceBuildId, "source-build-id", "", "Source Build ID. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "source-build-id") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3353,9 +3430,10 @@ func NewTemporalTaskQueueVersioningGetRulesCommand(cctx *CommandContext, parent s.Command.Args = cobra.NoArgs s.Command.Annotations = make(map[string]string) s.Command.Annotations["deprecationWarning"] = "This API has been deprecated by Worker Deployment." - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3388,9 +3466,10 @@ func NewTemporalTaskQueueVersioningInsertAssignmentRuleCommand(cctx *CommandCont s.Command.Flags().IntVarP(&s.RuleIndex, "rule-index", "i", 0, "Insertion position. Ranges from 0 (insert at start) to count (append). Any number greater than the count is treated as \"append\".") s.Command.Flags().IntVar(&s.Percentage, "percentage", 100, "Traffic percent to send to target Build ID.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3426,9 +3505,10 @@ func NewTemporalTaskQueueVersioningReplaceAssignmentRuleCommand(cctx *CommandCon s.Command.Flags().IntVar(&s.Percentage, "percentage", 100, "Divert percent of traffic to target Build ID.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm.") s.Command.Flags().BoolVar(&s.Force, "force", false, "Bypass the validation that one unconditional rule remains.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3460,9 +3540,10 @@ func NewTemporalTaskQueueVersioningReplaceRedirectRuleCommand(cctx *CommandConte s.Command.Flags().StringVar(&s.TargetBuildId, "target-build-id", "", "Target Build ID. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "target-build-id") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3541,9 +3622,10 @@ func NewTemporalWorkerDeploymentCreateCommand(cctx *CommandContext, parent *Temp } s.Command.Args = cobra.NoArgs s.DeploymentNameOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3573,9 +3655,10 @@ func NewTemporalWorkerDeploymentCreateVersionCommand(cctx *CommandContext, paren s.Command.Flags().StringVar(&s.AwsLambdaAssumeRoleArn, "aws-lambda-assume-role-arn", "", "AWS IAM role ARN that the Temporal server will assume when invoking the Lambda function that spawns a new Worker in this Worker Deployment Version. Required when --aws-lambda-function-arn is specified.") s.Command.Flags().StringVar(&s.AwsLambdaAssumeRoleExternalId, "aws-lambda-assume-role-external-id", "", "Temporal server will enforce that the AWS IAM trust policy associated with the AWS IAM role specified in --aws-lambda-assume-role-arn has an aws:ExternalId condition that matches the supplied value. Required when --aws-lambda-function-arn is specified.") s.DeploymentVersionOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3599,9 +3682,10 @@ func NewTemporalWorkerDeploymentDeleteCommand(cctx *CommandContext, parent *Temp } s.Command.Args = cobra.NoArgs s.DeploymentNameOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3627,9 +3711,10 @@ func NewTemporalWorkerDeploymentDeleteVersionCommand(cctx *CommandContext, paren s.Command.Args = cobra.NoArgs s.Command.Flags().BoolVar(&s.SkipDrainage, "skip-drainage", false, "Ignore the deletion requirement of not draining.") s.DeploymentVersionOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3653,9 +3738,10 @@ func NewTemporalWorkerDeploymentDescribeCommand(cctx *CommandContext, parent *Te } s.Command.Args = cobra.NoArgs s.DeploymentNameOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3681,9 +3767,10 @@ func NewTemporalWorkerDeploymentDescribeVersionCommand(cctx *CommandContext, par s.Command.Args = cobra.NoArgs s.Command.Flags().BoolVar(&s.ReportTaskQueueStats, "report-task-queue-stats", false, "Report stats for task queues that are present in this version.") s.DeploymentVersionOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3705,9 +3792,10 @@ func NewTemporalWorkerDeploymentListCommand(cctx *CommandContext, parent *Tempor s.Command.Long = "List existing Worker Deployments in the client's namespace.\n\n```\ntemporal worker deployment list [options]\n```\n\nFor example, listing Deployments in YourDeploymentNamespace:\n\n```\ntemporal worker deployment list \\\n --namespace YourDeploymentNamespace\n```" } s.Command.Args = cobra.NoArgs - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3758,9 +3846,10 @@ func NewTemporalWorkerDeploymentManagerIdentitySetCommand(cctx *CommandContext, s.Command.Flags().BoolVar(&s.Self, "self", false, "Set Manager Identity to the identity of the user submitting this request. Required unless --manager-identity is specified.") s.Command.Flags().StringVar(&s.DeploymentName, "deployment-name", "", "Name for a Worker Deployment. Required.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm set Manager Identity.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3786,9 +3875,10 @@ func NewTemporalWorkerDeploymentManagerIdentityUnsetCommand(cctx *CommandContext s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.DeploymentName, "deployment-name", "", "Name for a Worker Deployment. Required.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm unset Manager Identity.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3818,9 +3908,10 @@ func NewTemporalWorkerDeploymentSetCurrentVersionCommand(cctx *CommandContext, p s.Command.Flags().BoolVar(&s.AllowNoPollers, "allow-no-pollers", false, "Override protection and set version as current even if it has no pollers.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm set Current Version.") s.DeploymentVersionOrUnversionedOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3854,9 +3945,10 @@ func NewTemporalWorkerDeploymentSetRampingVersionCommand(cctx *CommandContext, p s.Command.Flags().BoolVar(&s.AllowNoPollers, "allow-no-pollers", false, "Override protection and set version as ramping even if it has no pollers.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm set Ramping Version.") s.DeploymentVersionOrUnversionedOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3888,9 +3980,10 @@ func NewTemporalWorkerDeploymentUpdateVersionComputeConfigCommand(cctx *CommandC s.Command.Flags().StringVar(&s.AwsLambdaAssumeRoleExternalId, "aws-lambda-assume-role-external-id", "", "Temporal server will enforce that the AWS IAM trust policy associated with the AWS IAM role specified in --aws-lambda-assume-role-arn has an aws:ExternalId condition that matches the supplied value. Required when --aws-lambda-function-arn is specified.") s.Command.Flags().BoolVar(&s.Remove, "remove", false, "Removes any compute configuration associated with this Worker Deployment Version.") s.DeploymentVersionOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3918,9 +4011,10 @@ func NewTemporalWorkerDeploymentUpdateVersionMetadataCommand(cctx *CommandContex s.Command.Flags().StringArrayVar(&s.Metadata, "metadata", nil, "Set deployment metadata using `KEY=\"VALUE\"` pairs. Keys must be identifiers, and values must be JSON values. For example: `YourKey={\"your\": \"value\"}` Can be passed multiple times.") s.Command.Flags().StringArrayVar(&s.RemoveEntries, "remove-entries", nil, "Keys of entries to be deleted from metadata. Can be passed multiple times.") s.DeploymentVersionOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3945,9 +4039,10 @@ func NewTemporalWorkerDescribeCommand(cctx *CommandContext, parent *TemporalWork s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.WorkerInstanceKey, "worker-instance-key", "", "Worker instance key to describe. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "worker-instance-key") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -3973,9 +4068,10 @@ func NewTemporalWorkerListCommand(cctx *CommandContext, parent *TemporalWorkerCo s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Content for an SQL-like `QUERY` List Filter.") s.Command.Flags().IntVar(&s.Limit, "limit", 0, "Maximum number of workers to display.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4044,9 +4140,10 @@ func NewTemporalWorkflowCancelCommand(cctx *CommandContext, parent *TemporalWork } s.Command.Args = cobra.NoArgs s.SingleWorkflowOrBatchOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4070,9 +4167,10 @@ func NewTemporalWorkflowCountCommand(cctx *CommandContext, parent *TemporalWorkf } s.Command.Args = cobra.NoArgs s.Command.Flags().StringVarP(&s.Query, "query", "q", "", "Content for an SQL-like `QUERY` List Filter.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4096,9 +4194,10 @@ func NewTemporalWorkflowDeleteCommand(cctx *CommandContext, parent *TemporalWork } s.Command.Args = cobra.NoArgs s.SingleWorkflowOrBatchOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4126,9 +4225,10 @@ func NewTemporalWorkflowDescribeCommand(cctx *CommandContext, parent *TemporalWo s.Command.Flags().BoolVar(&s.ResetPoints, "reset-points", false, "Show auto-reset points only.") s.Command.Flags().BoolVar(&s.Raw, "raw", false, "Print properties without changing their format.") s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4161,9 +4261,10 @@ func NewTemporalWorkflowExecuteCommand(cctx *CommandContext, parent *TemporalWor s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "name": "type", })) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4212,9 +4313,10 @@ func NewTemporalWorkflowExecuteUpdateWithStartCommand(cctx *CommandContext, pare "name": "type", "update-type": "update-name", })) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4241,9 +4343,10 @@ func NewTemporalWorkflowFixHistoryJsonCommand(cctx *CommandContext, parent *Temp s.Command.Flags().StringVarP(&s.Source, "source", "s", "", "Path to the original file. Required.") _ = cobra.MarkFlagRequired(s.Command.Flags(), "source") s.Command.Flags().StringVarP(&s.Target, "target", "t", "", "Path to the results file. When omitted, output is sent to stdout.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4273,9 +4376,10 @@ func NewTemporalWorkflowListCommand(cctx *CommandContext, parent *TemporalWorkfl s.Command.Flags().BoolVar(&s.Archived, "archived", false, "Limit output to archived Workflow Executions. EXPERIMENTAL.") s.Command.Flags().IntVar(&s.Limit, "limit", 0, "Maximum number of Workflow Executions to display.") s.Command.Flags().IntVar(&s.PageSize, "page-size", 0, "Maximum number of Workflow Executions to fetch at a time from the server.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4301,9 +4405,10 @@ func NewTemporalWorkflowMetadataCommand(cctx *CommandContext, parent *TemporalWo s.Command.Args = cobra.NoArgs s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) s.QueryModifiersOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4325,9 +4430,10 @@ func NewTemporalWorkflowPauseCommand(cctx *CommandContext, parent *TemporalWorkf s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for pausing the Workflow Execution. Defaults to message with the current user's name.") s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4361,9 +4467,10 @@ func NewTemporalWorkflowQueryCommand(cctx *CommandContext, parent *TemporalWorkf s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "type": "name", })) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4410,9 +4517,10 @@ func NewTemporalWorkflowResetCommand(cctx *CommandContext, parent *TemporalWorkf s.Command.PersistentFlags().StringVar(&s.BuildId, "build-id", "", "A Build ID. Use only with the BuildId `--type`. Resets the first Workflow task processed by this ID. By default, this reset may be in a prior run, earlier than a Continue as New point.") s.Command.PersistentFlags().StringVarP(&s.Query, "query", "q", "", "Content for an SQL-like `QUERY` List Filter.") s.Command.PersistentFlags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm. Only allowed when `--query` is present.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4432,9 +4540,10 @@ func NewTemporalWorkflowResetWithWorkflowUpdateOptionsCommand(cctx *CommandConte s.Command.Long = "Run Workflow Update Options atomically after the Workflow is reset.\nWorkflows selected by the reset command are forwarded onto the subcommand." s.Command.Args = cobra.NoArgs s.WorkflowUpdateOptionsOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4458,9 +4567,10 @@ func NewTemporalWorkflowResultCommand(cctx *CommandContext, parent *TemporalWork } s.Command.Args = cobra.NoArgs s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4490,9 +4600,10 @@ func NewTemporalWorkflowShowCommand(cctx *CommandContext, parent *TemporalWorkfl s.Command.Flags().BoolVar(&s.Detailed, "detailed", false, "Display events as detailed sections instead of table. Does not apply to JSON output.") s.Command.Flags().BoolVar(&s.Reverse, "reverse", false, "Fetch Event History newest-event-first. Cannot be combined with --follow.") s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4524,9 +4635,10 @@ func NewTemporalWorkflowSignalCommand(cctx *CommandContext, parent *TemporalWork s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "type": "name", })) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4569,9 +4681,10 @@ func NewTemporalWorkflowSignalWithStartCommand(cctx *CommandContext, parent *Tem "name": "type", "signal-type": "signal-name", })) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4598,9 +4711,10 @@ func NewTemporalWorkflowStackCommand(cctx *CommandContext, parent *TemporalWorkf s.RejectCondition = cliext.NewFlagStringEnum([]string{"not_open", "not_completed_cleanly"}, "") s.Command.Flags().Var(&s.RejectCondition, "reject-condition", "Optional flag to reject Queries based on Workflow state. Accepted values: not_open, not_completed_cleanly.") s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4631,9 +4745,10 @@ func NewTemporalWorkflowStartCommand(cctx *CommandContext, parent *TemporalWorkf s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "name": "type", })) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4686,9 +4801,10 @@ func NewTemporalWorkflowStartUpdateWithStartCommand(cctx *CommandContext, parent "name": "type", "update-type": "update-name", })) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4722,9 +4838,10 @@ func NewTemporalWorkflowTerminateCommand(cctx *CommandContext, parent *TemporalW s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for termination. Defaults to message with the current user's name.") s.Command.Flags().BoolVarP(&s.Yes, "yes", "y", false, "Don't prompt to confirm termination. Can only be used with --query.") s.Command.Flags().Float32Var(&s.Rps, "rps", 0, "Limit batch's requests per second. Only allowed if query is present.") - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4756,9 +4873,10 @@ func NewTemporalWorkflowTraceCommand(cctx *CommandContext, parent *TemporalWorkf s.Command.Flags().IntVar(&s.Depth, "depth", -1, "Set depth for your Child Workflow fetches. Pass -1 to fetch child workflows at any depth.") s.Command.Flags().IntVar(&s.Concurrency, "concurrency", 10, "Number of Workflow Histories to fetch at a time.") s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4780,9 +4898,10 @@ func NewTemporalWorkflowUnpauseCommand(cctx *CommandContext, parent *TemporalWor s.Command.Args = cobra.NoArgs s.Command.Flags().StringVar(&s.Reason, "reason", "", "Reason for unpausing the Workflow Execution. Defaults to message with the current user's name.") s.WorkflowReferenceOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4825,9 +4944,10 @@ func NewTemporalWorkflowUpdateDescribeCommand(cctx *CommandContext, parent *Temp } s.Command.Args = cobra.NoArgs s.UpdateTargetingOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4856,9 +4976,10 @@ func NewTemporalWorkflowUpdateExecuteCommand(cctx *CommandContext, parent *Tempo s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "type": "name", })) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4882,9 +5003,10 @@ func NewTemporalWorkflowUpdateResultCommand(cctx *CommandContext, parent *Tempor } s.Command.Args = cobra.NoArgs s.UpdateTargetingOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4917,9 +5039,10 @@ func NewTemporalWorkflowUpdateStartCommand(cctx *CommandContext, parent *Tempora s.Command.Flags().SetNormalizeFunc(aliasNormalizer(map[string]string{ "type": "name", })) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } @@ -4951,9 +5074,10 @@ func NewTemporalWorkflowUpdateOptionsCommand(cctx *CommandContext, parent *Tempo s.Command.Flags().StringVar(&s.VersioningOverrideDeploymentName, "versioning-override-deployment-name", "", "When overriding to a `pinned` or `one_time` behavior, specifies the Deployment Name of the version to target.") s.Command.Flags().StringVar(&s.VersioningOverrideBuildId, "versioning-override-build-id", "", "When overriding to a `pinned` or `one_time` behavior, specifies the Build ID of the version to target.") s.SingleWorkflowOrBatchOptions.BuildFlags(s.Command.Flags()) - s.Command.RunE = func(c *cobra.Command, args []string) error { - cctx.commandRunStarted = true - return s.run(cctx, args) + s.Command.Run = func(c *cobra.Command, args []string) { + if err := s.run(cctx, args); err != nil { + cctx.Options.Fail(err) + } } return &s } diff --git a/internal/temporalcli/commands.go b/internal/temporalcli/commands.go index 01238a212..2dee3d5be 100644 --- a/internal/temporalcli/commands.go +++ b/internal/temporalcli/commands.go @@ -58,17 +58,10 @@ type CommandContext struct { // Is set to true if any command actually started running. This is a hack to workaround the fact // that cobra does not properly exit nonzero if an unknown command/subcommand is given. ActuallyRanCommand bool - preRunSucceeded bool - commandRunStarted bool - terminalColor bool // Root/current command only set inside of pre-run RootCommand *TemporalCommand CurrentCommand *cobra.Command - commandCancel context.CancelFunc - secretValues []string - hasAPIKey bool - hasOAuth bool } type IOStreams struct { @@ -88,6 +81,9 @@ type CommandOptions struct { // If nil, [envconfig.EnvLookupOS] is used. EnvLookup envconfig.EnvLookup + // Defaults to logging error then os.Exit(1) + Fail func(error) + AdditionalClientGRPCDialOptions []grpc.DialOption } @@ -141,6 +137,27 @@ func (c *CommandContext) preprocessOptions() error { c.Options.Stderr = os.Stderr } + // Setup default fail callback + if c.Options.Fail == nil { + c.Options.Fail = func(err error) { + // If context is closed, say that the program was interrupted and ignore + // the actual error + if c.Err() != nil { + err = fmt.Errorf("program interrupted") + } + if exitError, ok := errors.AsType[ExtensionNonZeroExit](err); ok { + // An extension failed after being found and successfully started. Here we defer + // to its own error handling logic, and just copy the exit code through. + os.Exit(exitError.ExitCode()) + } + if writeConnectionError(c.Options.Stderr, err, !color.NoColor) { + os.Exit(1) + } + fmt.Fprintf(c.Options.Stderr, "Error: %v\n", err) + os.Exit(1) + } + } + // Update options according to the env file. MUST BE DONE LAST. // // Why last? Callers need the CommandContext to be usable no matter what, @@ -346,34 +363,22 @@ func (c *CommandContext) promptString(message string, expected string, autoConfi return line == expected, nil } -// Execute runs the Temporal CLI and returns its terminal outcome. It never -// exits for a terminal command failure; cmd/temporal/main.go owns that process -// exit boundary. Existing success-output broken-pipe exits in printer are a -// separate compatibility path outside terminal error handling. -func Execute(ctx context.Context, options CommandOptions) Result { - origNoColor := color.NoColor - defer func() { color.NoColor = origNoColor }() - +// Execute runs the Temporal CLI with the given context and options. This +// intentionally does not return an error but rather invokes Fail on the +// options. +func Execute(ctx context.Context, options CommandOptions) { // Create context and run. We always get a context and cancel func back even - // if an error was returned. This lets the terminal handler use the resolved - // streams and options regardless of why the failure occurred. + // if an error was returned. This is so we can use the context to print an + // error message using the appropriate Fail() method, regardless of why the + // failure occurred. // // (In most cases, an error here likely means a problem with the user's env // config file, or some other issue in their environment.) cctx, cancel, err := NewCommandContext(ctx, options) - cctx.terminalColor = !origNoColor defer cancel() - defer func() { - if cctx.commandCancel != nil { - cctx.commandCancel() - } - }() if err == nil { cmd := NewTemporalCommand(cctx) - // The terminal handler owns both error and input-error usage rendering. - cmd.Command.SilenceErrors = true - cmd.Command.SilenceUsage = true cmd.Command.SetArgs(cctx.Options.Args) cmd.Command.SetOut(cctx.Options.Stdout) cmd.Command.SetErr(cctx.Options.Stderr) @@ -381,29 +386,20 @@ func Execute(ctx context.Context, options CommandOptions) Result { // Try extension first. err, cctx.ActuallyRanCommand = tryExecuteExtension(cctx, cmd) if err != nil { - return finishCommand(cctx, err, usageForArgs(&cmd.Command, cctx.Options.Args)) + cctx.Options.Fail(err) + return } // Run builtin command if no extension handled the command. if !cctx.ActuallyRanCommand { - if unknownCommandPath(&cmd.Command, cctx.Options.Args) { - return finishCommand(cctx, fmt.Errorf("unknown command"), usageForArgs(&cmd.Command, nil)) - } - cobraErr := cmd.Command.ExecuteContext(cctx) - if cobraErr != nil { - usage := "" - if !cctx.ActuallyRanCommand || (cctx.preRunSucceeded && !cctx.commandRunStarted) { - usage = usageForArgs(&cmd.Command, cctx.Options.Args) - } - return finishCommand(cctx, cobraErr, usage) - } + err = cmd.Command.ExecuteContext(cctx) } } if err != nil { // Either we failed to create the context, OR the command itself failed. // Either way, we need to print an error message. - return finishCommand(cctx, err, "") + cctx.Options.Fail(err) } // If no command ever actually got run, exit nonzero with an error. This is @@ -420,165 +416,9 @@ func Execute(ctx context.Context, options CommandOptions) Result { if slices.ContainsFunc(cctx.Options.Args, func(a string) bool { return slices.Contains(zeroExitArgs, a) }) { - return Result{} - } - return finishCommand(cctx, fmt.Errorf("unknown command"), "") - } - return Result{} -} - -func usageForArgs(root *cobra.Command, args []string) string { - command, _, findErr := root.Find(args) - if findErr != nil || command == nil { - command = root - } - return command.UsageString() -} - -func unknownCommandPath(root *cobra.Command, args []string) bool { - current := root - for i := 0; i < len(args); i++ { - arg := args[i] - if strings.HasPrefix(arg, "-") { - name, inline := parseFlagArg(arg) - flag, takesValue := lookupFlag(current, name) - if flag == nil { - // Cobra owns invalid-flag classification. Guessing whether an - // unknown flag consumes the next token can misreport that token - // as an unknown command. - return false - } - if takesValue && !inline { - i++ - } - continue - } - var next *cobra.Command - for _, child := range current.Commands() { - if child.Name() == arg || slices.Contains(child.Aliases, arg) { - next = child - break - } - } - if next == nil { - return current.HasAvailableSubCommands() && current.Run == nil && current.RunE == nil - } - current = next - } - return false -} - -func finishCommand(cctx *CommandContext, err error, usage string) Result { - var extensionErr ExtensionNonZeroExit - if errors.As(err, &extensionErr) { - return Result{CommandErr: err, ExitStatus: extensionErr.ExitCode()} - } - displayErr := err - if cctx.Err() != nil { - if cause := context.Cause(cctx); cause != nil && !errors.Is(cause, context.Canceled) { - displayErr = cause - } else { - displayErr = fmt.Errorf("program interrupted") - } - } - return handleTerminalError(err, terminalOptions{ - Stderr: cctx.Options.Stderr, - Color: cctx.terminalColorEnabled(), - KnownSecrets: cctx.knownSecrets(), - Usage: usage, - DisplayErr: displayErr, - }) -} - -func (c *CommandContext) terminalColorEnabled() bool { - jsonOutput := c.JSONOutput - colorEnabled := c.terminalColor - for i := 0; i < len(c.Options.Args); i++ { - name, value, inline := strings.Cut(c.Options.Args[i], "=") - if !inline && i+1 < len(c.Options.Args) && (name == "-o" || name == "--output" || name == "--color") { - i++ - value = c.Options.Args[i] - } - switch name { - case "-o", "--output": - jsonOutput = jsonOutput || value == "json" || value == "jsonl" - case "--color": - if value == "always" { - colorEnabled = true - } else if value == "never" { - colorEnabled = false - } - } - } - if c.RootCommand != nil { - jsonOutput = jsonOutput || c.RootCommand.Output.Value == "json" || c.RootCommand.Output.Value == "jsonl" - switch c.RootCommand.Color.Value { - case "always": - colorEnabled = true - case "never": - colorEnabled = false - } - } - return colorEnabled && !jsonOutput -} - -func (c *CommandContext) knownSecrets() []string { - secretFlags := map[string]bool{ - "api-key": true, "codec-auth": true, "codec-header": true, - "grpc-meta": true, "tls-cert-data": true, "tls-key-data": true, - "tls-ca-data": true, - } - secrets := append([]string(nil), c.secretValues...) - if c.configEnvironmentEnabled() { - for _, name := range []string{ - "TEMPORAL_API_KEY", "TEMPORAL_CODEC_AUTH", "TEMPORAL_TLS_CERT_DATA", - "TEMPORAL_TLS_KEY_DATA", "TEMPORAL_TLS_CA_DATA", - } { - if value, ok := c.Options.EnvLookup.LookupEnv(name); ok && value != "" { - secrets = append(secrets, value) - } - } - } - if c.CurrentCommand == nil { - return secrets - } - appendFlagValues := func(flag *pflag.Flag) { - if values, ok := flag.Value.(pflag.SliceValue); ok { - for _, value := range values.GetSlice() { - secrets = append(secrets, value) - if _, secret, found := strings.Cut(value, "="); found && secret != "" { - secrets = append(secrets, secret) - } - } return } - secrets = append(secrets, flag.Value.String()) - } - c.CurrentCommand.Flags().VisitAll(func(flag *pflag.Flag) { - if secretFlags[flag.Name] && flag.Changed { - appendFlagValues(flag) - } - }) - c.CurrentCommand.InheritedFlags().VisitAll(func(flag *pflag.Flag) { - if secretFlags[flag.Name] && flag.Changed { - appendFlagValues(flag) - } - }) - return secrets -} - -func (c *CommandContext) configEnvironmentEnabled() bool { - if c.RootCommand != nil { - return !c.RootCommand.CommonOptions.DisableConfigEnv - } - return !slices.Contains(c.Options.Args, "--disable-config-env") -} - -func (c *CommandContext) registerSecrets(values ...string) { - for _, value := range values { - if value != "" { - c.secretValues = append(c.secretValues, value) - } + cctx.Options.Fail(fmt.Errorf("unknown command")) } } @@ -680,7 +520,6 @@ func (c *TemporalCommand) initCommand(cctx *CommandContext) { } } } - cctx.preRunSucceeded = res == nil return res } c.Command.PersistentPostRun = func(*cobra.Command, []string) { @@ -693,7 +532,7 @@ var buildInfo string func VersionString() string { // To add build-time information to the version string, use // go build -ldflags "-X github.com/temporalio/cli/internal.buildInfo=" - var bi = buildInfo + bi := buildInfo if bi != "" { bi = fmt.Sprintf(", %s", bi) } @@ -745,7 +584,7 @@ func (c *TemporalCommand) preRun(cctx *CommandContext) error { } cctx.JSONShorthandPayloads = !c.NoJsonShorthandPayloads if c.CommandTimeout.Duration() > 0 { - cctx.Context, cctx.commandCancel = context.WithTimeoutCause( + cctx.Context, _ = context.WithTimeoutCause( cctx.Context, c.CommandTimeout.Duration(), fmt.Errorf("command timed out after %v", c.CommandTimeout.Duration()), diff --git a/internal/temporalcli/commands.schedule_test.go b/internal/temporalcli/commands.schedule_test.go index d49a33f52..d3bf978d9 100644 --- a/internal/temporalcli/commands.schedule_test.go +++ b/internal/temporalcli/commands.schedule_test.go @@ -38,8 +38,9 @@ func (s *SharedServerSuite) createSchedule(args ...string) (schedId, schedWfId s "--address", s.Address(), "-s", schedId, }, + Fail: func(error) {}, } - _ = temporalcli.Execute(ctx, options) + temporalcli.Execute(ctx, options) }) res = s.Execute(append([]string{ "schedule", "create", diff --git a/internal/temporalcli/commands_test.go b/internal/temporalcli/commands_test.go index 0a171d0e0..10e46aa91 100644 --- a/internal/temporalcli/commands_test.go +++ b/internal/temporalcli/commands_test.go @@ -6,8 +6,6 @@ import ( "fmt" "io" "log/slog" - "os" - "path/filepath" "regexp" "slices" "strings" @@ -122,38 +120,6 @@ func TestAssertContainsOnSameLine(t *testing.T) { require.NoError(t, AssertContainsOnSameLine("a a", "a", "a")) } -func TestExecuteReturnsStatusAndKeepsTerminalErrorsOnStderr(t *testing.T) { - badEnvFile := filepath.Join(t.TempDir(), "invalid.yaml") - require.NoError(t, os.WriteFile(badEnvFile, []byte("invalid: ["), 0o600)) - t.Run("help succeeds without terminal rendering", func(t *testing.T) { - res := NewCommandHarness(t).Execute("--help") - assert.NoError(t, res.Err) - assert.Zero(t, res.Runtime.ExitStatus) - assert.Empty(t, res.Stderr.String()) - assert.Contains(t, res.Stdout.String(), "Usage:") - }) - - for _, test := range []struct { - name string - args []string - wantUsage bool - }{ - {name: "parse failure", args: []string{"workflow", "describe", "--not-a-flag"}, wantUsage: true}, - {name: "required flag failure", args: []string{"workflow", "describe"}, wantUsage: true}, - {name: "pre-run/configuration failure", args: []string{"workflow", "list", "--env-file", badEnvFile}}, - {name: "runtime failure", args: []string{"config", "get", "--disable-config-file", "--disable-config-env"}}, - } { - t.Run(test.name, func(t *testing.T) { - res := NewCommandHarness(t).Execute(test.args...) - assert.Error(t, res.Err) - assert.Equal(t, 1, res.Runtime.ExitStatus) - assert.Equal(t, 1, strings.Count(res.Stderr.String(), "Error:")) - assert.Equal(t, test.wantUsage, strings.Contains(res.Stderr.String(), "Usage:")) - assert.Empty(t, res.Stdout.String()) - }) - } -} - func (h *CommandHarness) Eventually( condition func() bool, waitFor time.Duration, @@ -179,10 +145,9 @@ func (h *CommandHarness) T() *testing.T { } type CommandResult struct { - Err error - Runtime temporalcli.Result - Stdout bytes.Buffer - Stderr bytes.Buffer + Err error + Stdout bytes.Buffer + Stderr bytes.Buffer } func (h *CommandHarness) Execute(args ...string) *CommandResult { @@ -202,13 +167,20 @@ func (h *CommandHarness) Execute(args ...string) *CommandResult { if options.DeprecatedEnvConfig.DisableEnvConfig { options.DeprecatedEnvConfig.EnvConfigName = "default" } + // Capture error + options.Fail = func(err error) { + if res.Err != nil { + panic("fail called twice, just failed with " + err.Error()) + } + res.Err = err + } + // Run ctx, cancel := context.WithCancel(h.Context) h.t.Cleanup(cancel) defer cancel() h.t.Logf("Calling: %v", strings.Join(args, " ")) - res.Runtime = temporalcli.Execute(ctx, options) - res.Err = res.Runtime.CommandErr + temporalcli.Execute(ctx, options) if res.Stdout.Len() > 0 { h.t.Logf("Stdout:\n-----\n%s\n-----", &res.Stdout) } @@ -267,6 +239,7 @@ func (s *SharedServerSuite) SetupSuite() { // the __temporal_system endpoint from inside a workflow. "history.enableSignalWithStartFromWorkflow": true, "activity.enableStandalone": true, + "activity.startDelayEnabled": true, "activity.longPollTimeout": 2 * time.Second, "nexusoperation.enableStandalone": true, "history.enableChasmCallbacks": true, diff --git a/internal/temporalcli/connectdiag_test.go b/internal/temporalcli/connectdiag_test.go index 27feca3a0..9fcb6ec73 100644 --- a/internal/temporalcli/connectdiag_test.go +++ b/internal/temporalcli/connectdiag_test.go @@ -460,7 +460,7 @@ func TestSuggestFix(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := string(renderErrorText(errorReport{Summary: "failure", Action: suggestAction(tt.diag, tt.meta)}, renderOptions{})) + got := string(renderConnectionReport(connectionReport{Summary: "failure", Action: suggestAction(tt.diag, tt.meta)}, false, displayShellPOSIX)) for _, want := range tt.contains { assert.Contains(t, got, want) } @@ -489,7 +489,7 @@ func TestConnectErrorRendering(t *testing.T) { TLSConfigured: true, }, origErr) - msg := string(renderErrorText(connectionErrorReport(err), renderOptions{})) + msg := string(renderConnectionReport(connectionErrorReport(err), false, displayShellPOSIX)) assert.Contains(t, msg, "failed connecting to Temporal server at foo.bar.tmprl.cloud:7233: TLS handshake failed: server requires client certificate (mTLS)") assert.Contains(t, msg, "Namespace: example.namespace") assert.Contains(t, msg, "TLS: true") diff --git a/internal/temporalcli/terminal.go b/internal/temporalcli/terminal.go index d122de4ed..27cabb035 100644 --- a/internal/temporalcli/terminal.go +++ b/internal/temporalcli/terminal.go @@ -5,21 +5,10 @@ import ( "fmt" "io" "runtime" - "sort" "strings" "unicode" ) -const defaultFailureExitStatus = 1 - -// Result describes the terminal outcome without exiting the process. The -// original command error remains separate from any failure writing stderr. -type Result struct { - CommandErr error - PresentationErr error - ExitStatus int -} - type checkOutcome int const ( @@ -49,18 +38,12 @@ type displayAction struct { Invocations []displayInvocation } -type errorReport struct { +type connectionReport struct { Summary string Context []safeField CheckHeading string Checks []errorCheck Action *displayAction - Usage string -} - -type renderOptions struct { - Color bool - Shell displayShell } type displayShell int @@ -70,62 +53,28 @@ const ( displayShellPowerShell ) -type terminalOptions struct { - Stderr io.Writer - Color bool - KnownSecrets []string - Usage string - DisplayErr error -} - -func handleTerminalError(commandErr error, options terminalOptions) Result { - result := Result{CommandErr: commandErr, ExitStatus: defaultFailureExitStatus} - displayErr := options.DisplayErr - if displayErr == nil { - displayErr = commandErr +// writeConnectionError renders err only when it contains a connectError. It +// returns whether the error was handled, leaving all generic failures on the +// CLI's longstanding fallback path. +func writeConnectionError(stderr io.Writer, err error, useColor bool) bool { + var connectionErr *connectError + if !errors.As(err, &connectionErr) { + return false } - report := normalizeError(displayErr) - report.Usage = options.Usage - report = redactReport(report, options.KnownSecrets) + shell := displayShellPOSIX if runtime.GOOS == "windows" { shell = displayShellPowerShell } - rendered := renderErrorText(report, renderOptions{Color: options.Color, Shell: shell}) - n, err := options.Stderr.Write(rendered) - if err == nil && n != len(rendered) { - err = io.ErrShortWrite - } - result.PresentationErr = err - return result + _, _ = stderr.Write(renderConnectionReport(connectionErrorReport(connectionErr), useColor, shell)) + return true } -func normalizeError(err error) errorReport { - var connectionErr *connectError - if errors.As(err, &connectionErr) { - return connectionErrorReport(connectionErr) +func connectionErrorReport(err *connectError) connectionReport { + report := connectionReport{ + Summary: err.Error(), + Action: suggestAction(&err.diagnosis, err.meta), } - var activityErr *activityNotFoundError - if errors.As(err, &activityErr) { - return activityNotFoundErrorReport(activityErr) - } - if err == nil || err.Error() == "" { - return errorReport{Summary: "unknown error"} - } - return errorReport{Summary: err.Error()} -} - -// activityNotFoundErrorReport is the concrete terminal-owned adapter for a -// missing standalone Activity. The typed error retains identity and cause; -// this adapter preserves the deliberately conservative visible message. -func activityNotFoundErrorReport(_ *activityNotFoundError) errorReport { - return errorReport{Summary: "standalone Activity not found"} -} - -// connectionErrorReport is the concrete terminal-owned adapter for connection -// failures. connectError carries facts and never owns presentation layout. -func connectionErrorReport(err *connectError) errorReport { - report := errorReport{Summary: err.Error(), Action: suggestAction(&err.diagnosis, err.meta)} if err.meta.Namespace != "" { report.Context = append(report.Context, safeField{Label: "Namespace", Value: err.meta.Namespace}) } @@ -153,141 +102,53 @@ func connectionErrorReport(err *connectError) errorReport { return report } -func redactReport(report errorReport, secrets []string) errorReport { - // This is defense in depth for exact values already known to the runtime, - // not a claim that arbitrary legacy error prose is generically sanitized. - // Structured adapters remain responsible for admitting only safe fields. - report = cloneErrorReport(report) - orderedSecrets := append([]string(nil), secrets...) - sort.Slice(orderedSecrets, func(i, j int) bool { - if len(orderedSecrets[i]) == len(orderedSecrets[j]) { - return orderedSecrets[i] < orderedSecrets[j] - } - return len(orderedSecrets[i]) > len(orderedSecrets[j]) - }) - redact := func(value string) string { - for _, secret := range orderedSecrets { - if secret != "" { - value = strings.ReplaceAll(value, secret, "[REDACTED]") - } - } - return escapeTerminalControls(value) - } - report.Summary = redact(report.Summary) - // Usage is generated by Cobra and intentionally contains formatting - // newlines. It receives exact-value redaction but not control escaping. - for _, secret := range orderedSecrets { - if secret != "" { - report.Usage = strings.ReplaceAll(report.Usage, secret, "[REDACTED]") - } - } - for i := range report.Context { - report.Context[i].Label = redact(report.Context[i].Label) - report.Context[i].Value = redact(report.Context[i].Value) - } - report.CheckHeading = redact(report.CheckHeading) - for i := range report.Checks { - report.Checks[i].Message = redact(report.Checks[i].Message) - } - if report.Action != nil { - report.Action.Label = redact(report.Action.Label) - for invocationIndex := range report.Action.Invocations { - invocation := &report.Action.Invocations[invocationIndex] - for i := range invocation.Command { - invocation.Command[i] = redact(invocation.Command[i]) - } - for i := range invocation.Args { - invocation.Args[i] = redact(invocation.Args[i]) - } - } - } - return report -} - -func cloneErrorReport(report errorReport) errorReport { - clone := report - clone.Context = append([]safeField(nil), report.Context...) - clone.Checks = append([]errorCheck(nil), report.Checks...) - if report.Action == nil { - return clone - } - action := *report.Action - action.Invocations = append([]displayInvocation(nil), report.Action.Invocations...) - for i := range action.Invocations { - action.Invocations[i].Command = append([]string(nil), action.Invocations[i].Command...) - action.Invocations[i].Args = append([]string(nil), action.Invocations[i].Args...) - } - clone.Action = &action - return clone -} - -func escapeTerminalControls(value string) string { - var b strings.Builder - for _, r := range value { - if !unicode.IsControl(r) { - b.WriteRune(r) - continue - } - switch r { - case '\n': - b.WriteString(`\n`) - case '\r': - b.WriteString(`\r`) - case '\t': - b.WriteString(`\t`) - default: - fmt.Fprintf(&b, `\u{%x}`, r) - } - } - return b.String() -} - -// renderErrorText is total over errorReport and performs no I/O or global -// color lookup. Reports are redacted before reaching this function. -func renderErrorText(report errorReport, options renderOptions) []byte { - if report.Summary == "" { - report.Summary = "unknown error" - } +func renderConnectionReport(report connectionReport, useColor bool, shell displayShell) []byte { var b strings.Builder b.WriteString("Error: ") - b.WriteString(report.Summary) + b.WriteString(escapeTerminalControls(report.Summary)) b.WriteByte('\n') for _, field := range report.Context { - fmt.Fprintf(&b, " %s: %s\n", field.Label, field.Value) + fmt.Fprintf( + &b, + " %s: %s\n", + escapeTerminalControls(field.Label), + escapeTerminalControls(field.Value), + ) } if len(report.Checks) > 0 { heading := report.CheckHeading if heading == "" { heading = "Connecting" } - fmt.Fprintf(&b, "\n %s\n", heading) + fmt.Fprintf(&b, "\n %s\n", escapeTerminalControls(heading)) for _, check := range report.Checks { symbol := "✓" colorCode := "32" - if check.Outcome == checkFailed { + switch check.Outcome { + case checkFailed: symbol = "✗" colorCode = "31" - } else if check.Outcome == checkInconclusive { + case checkInconclusive: symbol = "?" colorCode = "33" - } else if check.Outcome == checkSkipped { + case checkSkipped: symbol = "-" colorCode = "33" } - if options.Color { + if useColor { symbol = "\x1b[" + colorCode + "m" + symbol + "\x1b[0m" } - fmt.Fprintf(&b, " %s %s\n", symbol, check.Message) + fmt.Fprintf(&b, " %s %s\n", symbol, escapeTerminalControls(check.Message)) } } if report.Action != nil { b.WriteByte('\n') if report.Action.Label != "" { - b.WriteString(indentLines(report.Action.Label, " ")) + b.WriteString(indentLines(escapeTerminalControls(report.Action.Label), " ")) b.WriteByte('\n') } for _, invocation := range report.Action.Invocations { - rendered, ok := renderInvocation(invocation, options.Shell) + rendered, ok := renderInvocation(invocation, shell) if !ok { continue } @@ -297,13 +158,6 @@ func renderErrorText(report errorReport, options renderOptions) []byte { b.WriteByte('\n') } } - if report.Usage != "" { - b.WriteByte('\n') - b.WriteString(strings.TrimLeft(report.Usage, "\n")) - if !strings.HasSuffix(report.Usage, "\n") { - b.WriteByte('\n') - } - } return []byte(b.String()) } @@ -341,6 +195,27 @@ func quotePowerShell(value string) string { return "'" + strings.ReplaceAll(value, "'", "''") + "'" } +func escapeTerminalControls(value string) string { + var b strings.Builder + for _, r := range value { + if !unicode.IsControl(r) { + b.WriteRune(r) + continue + } + switch r { + case '\n': + b.WriteString(`\n`) + case '\r': + b.WriteString(`\r`) + case '\t': + b.WriteString(`\t`) + default: + fmt.Fprintf(&b, `\u{%x}`, r) + } + } + return b.String() +} + func indentLines(s, indent string) string { lines := strings.Split(s, "\n") for i, line := range lines { diff --git a/internal/temporalcli/terminal_test.go b/internal/temporalcli/terminal_test.go index 664f6c32c..1807c0be0 100644 --- a/internal/temporalcli/terminal_test.go +++ b/internal/temporalcli/terminal_test.go @@ -2,40 +2,41 @@ package temporalcli import ( "bytes" - "context" "errors" "fmt" - "io" - "os" "strings" "testing" - "github.com/fatih/color" - "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestHandleTerminalErrorWritesOneRedactedReportAndRetainsBothErrors(t *testing.T) { - commandErr := errors.New("request rejected for token exact-secret") - writeErr := errors.New("stderr closed") - writer := &countingErrorWriter{err: writeErr} +func TestWriteConnectionErrorHandlesWrappedConnectError(t *testing.T) { + cause := errors.New("dial failed") + err := fmt.Errorf("client setup: %w", newConnectError(&connectDiagnosis{ + Address: "127.0.0.1:7233", + Cause: causeTCPRefused, + Stages: []diagStage{{Status: diagFail, Label: "TCP connection refused"}}, + }, connectMeta{Address: "127.0.0.1:7233", Namespace: "default"}, cause)) - result := handleTerminalError(commandErr, terminalOptions{ - Stderr: writer, - KnownSecrets: []string{"exact-secret"}, - }) + stderr := &countingWriter{} + assert.True(t, writeConnectionError(stderr, err, false)) + assert.Equal(t, 1, stderr.writes) + assert.Contains(t, stderr.String(), "Error: failed connecting to Temporal server at 127.0.0.1:7233: connection refused") + assert.Contains(t, stderr.String(), "Namespace: default") + assert.Contains(t, stderr.String(), "✗ TCP connection refused") + assert.Contains(t, stderr.String(), "temporal server start-dev") + assert.NotContains(t, stderr.String(), "\x1b[") +} - assert.Equal(t, 1, result.ExitStatus) - assert.ErrorIs(t, result.CommandErr, commandErr) - assert.ErrorIs(t, result.PresentationErr, writeErr) - assert.Equal(t, 1, writer.writes) - assert.Contains(t, string(writer.last), "Error: request rejected for token [REDACTED]") - assert.NotContains(t, string(writer.last), "exact-secret") +func TestWriteConnectionErrorLeavesGenericErrorsUnhandled(t *testing.T) { + var stderr bytes.Buffer + assert.False(t, writeConnectionError(&stderr, errors.New("ordinary failure"), true)) + assert.Empty(t, stderr.String()) } -func TestRenderErrorTextIsTotalAndUsesExplicitColorPolicy(t *testing.T) { - report := errorReport{ +func TestRenderConnectionReportUsesExplicitColorPolicy(t *testing.T) { + report := connectionReport{ Summary: "connection refused", Checks: []errorCheck{{ Outcome: checkFailed, @@ -43,264 +44,77 @@ func TestRenderErrorTextIsTotalAndUsesExplicitColorPolicy(t *testing.T) { }}, } - plain := string(renderErrorText(report, renderOptions{Color: false})) - colored := string(renderErrorText(report, renderOptions{Color: true})) + plain := string(renderConnectionReport(report, false, displayShellPOSIX)) + colored := string(renderConnectionReport(report, true, displayShellPOSIX)) assert.Equal(t, "Error: connection refused\n\n Connecting\n ✗ TCP connection refused\n", plain) assert.NotContains(t, plain, "\x1b[") - assert.Contains(t, colored, "\x1b[") - assert.Equal(t, []byte("Error: unknown error\n"), renderErrorText(errorReport{}, renderOptions{})) + assert.Contains(t, colored, "\x1b[31m✗\x1b[0m") } -func TestConnectErrorCarriesSemanticFactsWithoutRenderedStateOrRawArguments(t *testing.T) { - cause := errors.New("dial failed") - diagnosis := &connectDiagnosis{ - Address: "127.0.0.1:7233", - Cause: causeTCPRefused, - Stages: []diagStage{{Status: diagFail, Label: "TCP connection refused"}}, +func TestConnectionReportEscapesTerminalControls(t *testing.T) { + report := connectionReport{ + Summary: "bad\x1b[31m", + Context: []safeField{{Label: "Target\nName", Value: "host\tname"}}, + CheckHeading: "Check\rHeading", + Checks: []errorCheck{{Outcome: checkFailed, Message: "failed\ncheck"}}, + Action: &displayAction{Label: "retry\tnow"}, } - err := newConnectError(diagnosis, connectMeta{Address: diagnosis.Address}, cause) - diagnosis.Stages[0].Label = "mutated after construction" - assert.Equal(t, "failed connecting to Temporal server at 127.0.0.1:7233: connection refused", err.Error()) - assert.ErrorIs(t, err, cause) - report := normalizeError(err) - assert.Equal(t, "TCP connection refused", report.Checks[0].Message, "connect error must copy diagnosis stages") - require.NotNil(t, report.Action) - require.Len(t, report.Action.Invocations, 1) - assert.Equal(t, []string{"temporal", "server", "start-dev"}, report.Action.Invocations[0].Command) - assert.NotContains(t, err.Error(), "\n") + rendered := string(renderConnectionReport(report, false, displayShellPOSIX)) + assert.Contains(t, rendered, `bad\u{1b}[31m`) + assert.Contains(t, rendered, `Target\nName: host\tname`) + assert.Contains(t, rendered, `Check\rHeading`) + assert.Contains(t, rendered, `failed\ncheck`) + assert.Contains(t, rendered, `retry\tnow`) } -func TestNormalizeErrorOwnsActivityNotFoundPresentationAndPreservesUnknownActivityErrors(t *testing.T) { - notFound := &activityNotFoundError{ - activityID: "activity-id", - cause: errors.New("server detail"), +func TestRenderInvocationUsesExplicitShellQuoting(t *testing.T) { + invocation := displayInvocation{ + Command: []string{"temporal", "config", "set"}, + Args: []string{"--value", "space and 'quote'", "--profile", "-prod"}, } - report := normalizeError(fmt.Errorf("poll activity: %w", notFound)) - - assert.Equal(t, "standalone Activity not found", report.Summary) - assert.Empty(t, report.Context) - assert.Empty(t, report.Checks) - assert.Nil(t, report.Action) - - unknown := errors.New("activity result unavailable") - assert.Equal(t, unknown.Error(), normalizeError(unknown).Summary) -} - -func TestRenderInvocationUsesExplicitShellQuotingAndEscapesControls(t *testing.T) { - invocation := displayInvocation{Command: []string{"temporal", "config", "set"}, Args: []string{"--value", "space and 'quote'", "--profile", "-prod"}} posix, ok := renderInvocation(invocation, displayShellPOSIX) require.True(t, ok) assert.Contains(t, posix, `'space and '"'"'quote'"'"''`) assert.Contains(t, posix, "--profile -prod") + powerShell, ok := renderInvocation(invocation, displayShellPowerShell) require.True(t, ok) assert.True(t, strings.HasPrefix(powerShell, "& 'temporal' ")) assert.Contains(t, powerShell, `'space and ''quote'''`) assert.Contains(t, powerShell, `'--profile' '-prod'`) - escaped, ok := renderInvocation(displayInvocation{Command: []string{"temporal"}, Args: []string{"unsafe\nvalue"}}, displayShellPOSIX) - require.True(t, ok) - assert.Equal(t, `temporal 'unsafe\nvalue'`, escaped) -} - -func TestRedactReportDeepCopiesEscapesControlsAndUsesLongestSecretFirst(t *testing.T) { - report := errorReport{ - Summary: "token-long\x1b[31m", - Context: []safeField{{Label: "Target\nName", Value: "token-long"}}, - CheckHeading: "Check\rHeading", - Checks: []errorCheck{{Outcome: checkFailed, Message: "token\ncheck"}}, - Action: &displayAction{ - Label: "use\ttoken-long", - Invocations: []displayInvocation{{Command: []string{"temporal"}, Args: []string{"token-long"}}}, - }, - } - secrets := []string{"token", "token-long"} - got := redactReport(report, secrets) - assert.Equal(t, `[REDACTED]\u{1b}[31m`, got.Summary) - assert.Equal(t, `Target\nName`, got.Context[0].Label) - assert.Equal(t, `Check\rHeading`, got.CheckHeading) - assert.Equal(t, `[REDACTED]\ncheck`, got.Checks[0].Message) - assert.Equal(t, `use\t[REDACTED]`, got.Action.Label) - assert.Equal(t, "token-long", report.Context[0].Value, "source context must not be mutated") - assert.Equal(t, "token-long", report.Action.Invocations[0].Args[0], "nested source slices must not be mutated") - assert.Equal(t, "[REDACTED]", got.Action.Invocations[0].Args[0]) - assert.Equal(t, []string{"token", "token-long"}, secrets, "sorting must not mutate the caller's secret list") -} - -func TestRenderInvocationPowerShellDoublesSingleQuotesWithinOneQuotedToken(t *testing.T) { - got, ok := renderInvocation(displayInvocation{Command: []string{"temporal"}, Args: []string{"it's one token"}}, displayShellPowerShell) + escaped, ok := renderInvocation( + displayInvocation{Command: []string{"temporal"}, Args: []string{"unsafe\nvalue"}}, + displayShellPOSIX, + ) require.True(t, ok) - assert.Equal(t, "& 'temporal' 'it''s one token'", got) -} - -func TestFinishCommandPreservesOriginalErrorWhilePresentingCancellationCause(t *testing.T) { - original := errors.New("original command failure") - ctx, cancel := context.WithCancelCause(t.Context()) - cancel(errors.New("command timed out after 2s")) - var stderr bytes.Buffer - cctx := &CommandContext{Context: ctx, Options: CommandOptions{IOStreams: IOStreams{Stderr: &stderr}, EnvLookup: testEnvLookup{}}} - result := finishCommand(cctx, original, "") - assert.ErrorIs(t, result.CommandErr, original) - assert.Contains(t, stderr.String(), "command timed out after 2s") - assert.NotContains(t, stderr.String(), original.Error()) -} - -func TestGeneratedLeavesReturnErrorsThroughRunE(t *testing.T) { - source, err := os.ReadFile("commands.gen.go") - require.NoError(t, err) - runECalls := bytes.Count(source, []byte("s.Command.RunE = func")) - returnCalls := bytes.Count(source, []byte("return s.run(cctx, args)")) - markerCalls := bytes.Count(source, []byte("cctx.commandRunStarted = true")) - - assert.Greater(t, runECalls, 100, "expected to inspect every generated command leaf") - assert.Equal(t, runECalls, returnCalls, "every generated RunE must return its run error") - assert.Equal(t, runECalls, markerCalls, "every generated RunE must mark command execution") - assert.NotContains(t, string(source), "Options.Fail") -} - -func TestExecuteRestoresColorAcrossFailureSources(t *testing.T) { - old := color.NoColor - t.Cleanup(func() { color.NoColor = old }) - - for name, args := range map[string][]string{ - "argument": {"workflow", "describe", "--color", "always"}, - "pre-run": {"workflow", "list", "--time-format", "invalid", "--color", "always"}, - "runtime": {"config", "get", "--disable-config-file", "--disable-config-env", "--color", "always"}, - } { - t.Run(name, func(t *testing.T) { - color.NoColor = true - var stdout, stderr bytes.Buffer - result := Execute(t.Context(), CommandOptions{ - Args: args, - IOStreams: IOStreams{Stdout: &stdout, Stderr: &stderr}, - DeprecatedEnvConfig: DeprecatedEnvConfig{DisableEnvConfig: true, EnvConfigName: "default"}, - }) - assert.Error(t, result.CommandErr) - assert.True(t, color.NoColor, "global color policy must be restored") - }) - } -} - -func TestEarlyJSONPolicyDisablesTerminalColor(t *testing.T) { - cctx := &CommandContext{Options: CommandOptions{Args: []string{"workflow", "describe", "--output=jsonl", "--color=always"}}} - assert.False(t, cctx.terminalColorEnabled()) -} - -func TestEarlyJSONFailuresRenderUsageWithoutANSI(t *testing.T) { - old := color.NoColor - color.NoColor = false - t.Cleanup(func() { color.NoColor = old }) - - for name, args := range map[string][]string{ - "json parse failure": {"workflow", "describe", "--output", "json", "--color", "always", "--not-a-flag"}, - "jsonl required failure": {"workflow", "describe", "--output=jsonl", "--color=always"}, - } { - t.Run(name, func(t *testing.T) { - var stdout, stderr bytes.Buffer - result := Execute(t.Context(), CommandOptions{ - Args: args, - IOStreams: IOStreams{Stdout: &stdout, Stderr: &stderr}, - DeprecatedEnvConfig: DeprecatedEnvConfig{DisableEnvConfig: true, EnvConfigName: "default"}, - }) - - assert.Error(t, result.CommandErr) - assert.Equal(t, 1, result.ExitStatus) - assert.Empty(t, stdout.String()) - assert.Contains(t, stderr.String(), "Usage:") - assert.NotContains(t, stderr.String(), "\x1b[") - assert.Equal(t, 1, bytes.Count(stderr.Bytes(), []byte("Error:"))) - }) - } -} - -func TestExecuteRuntimeErrorPreservesIdentityWithoutUsageAndRetainsPresentationError(t *testing.T) { - configPath := t.TempDir() - writeErr := errors.New("stderr unavailable") - stderr := &countingErrorWriter{err: writeErr} - var stdout bytes.Buffer - - result := Execute(t.Context(), CommandOptions{ - Args: []string{ - "config", "get", - "--config-file", configPath, - "--disable-config-env", - }, - IOStreams: IOStreams{Stdout: &stdout, Stderr: stderr}, - DeprecatedEnvConfig: DeprecatedEnvConfig{DisableEnvConfig: true, EnvConfigName: "default"}, - }) - - require.Error(t, result.CommandErr) - var pathErr *os.PathError - require.ErrorAs(t, result.CommandErr, &pathErr) - assert.Equal(t, configPath, pathErr.Path) - assert.ErrorIs(t, result.CommandErr, pathErr) - assert.ErrorIs(t, result.PresentationErr, writeErr) - assert.Equal(t, 1, result.ExitStatus) - assert.Equal(t, 1, stderr.writes) - assert.NotContains(t, string(stderr.last), "Usage:") - assert.Empty(t, stdout.String()) -} - -func TestUnknownFlagWithFollowingValueIsNotMisclassifiedAsUnknownCommand(t *testing.T) { - var stdout, stderr bytes.Buffer - result := Execute(t.Context(), CommandOptions{ - Args: []string{"--definitely-invalid", "value"}, - IOStreams: IOStreams{Stdout: &stdout, Stderr: &stderr}, - DeprecatedEnvConfig: DeprecatedEnvConfig{DisableEnvConfig: true}, - }) - require.Error(t, result.CommandErr) - assert.Contains(t, result.CommandErr.Error(), "unknown flag: --definitely-invalid") - assert.NotContains(t, result.CommandErr.Error(), "unknown command") + assert.Equal(t, `temporal 'unsafe\nvalue'`, escaped) } -func TestKnownSecretsExtractsAvailableScalarSliceHeaderAndEnvironmentValues(t *testing.T) { - cmd := &cobra.Command{} - cmd.Flags().String("api-key", "", "") - cmd.Flags().StringArray("grpc-meta", nil, "") - require.NoError(t, cmd.Flags().Parse([]string{"--api-key", "flag-secret", "--grpc-meta", "Authorization=header-secret"})) - cctx := &CommandContext{ - CurrentCommand: cmd, - Options: CommandOptions{EnvLookup: testEnvLookup{ - "TEMPORAL_CODEC_AUTH": "env-secret", - }}, +func TestConnectErrorCopiesDiagnosisBeforeRendering(t *testing.T) { + cause := errors.New("dial failed") + diagnosis := &connectDiagnosis{ + Address: "127.0.0.1:7233", + Cause: causeTCPRefused, + Stages: []diagStage{{Status: diagFail, Label: "TCP connection refused"}}, } + err := newConnectError(diagnosis, connectMeta{Address: diagnosis.Address}, cause) + diagnosis.Stages[0].Label = "mutated after construction" - secrets := cctx.knownSecrets() - assert.Contains(t, secrets, "flag-secret") - assert.Contains(t, secrets, "Authorization=header-secret") - assert.Contains(t, secrets, "header-secret") - assert.Contains(t, secrets, "env-secret") -} - -func TestKnownSecretsIgnoresDisabledConfigEnvironment(t *testing.T) { - cctx := &CommandContext{Options: CommandOptions{ - Args: []string{"--disable-config-env"}, - EnvLookup: testEnvLookup{"TEMPORAL_API_KEY": "disabled-env-secret"}, - }} - assert.NotContains(t, cctx.knownSecrets(), "disabled-env-secret") -} - -type testEnvLookup map[string]string - -func (e testEnvLookup) LookupEnv(name string) (string, bool) { - value, ok := e[name] - return value, ok + report := connectionErrorReport(err) + require.Len(t, report.Checks, 1) + assert.Equal(t, "TCP connection refused", report.Checks[0].Message) + assert.ErrorIs(t, err, cause) } -func (e testEnvLookup) Environ() []string { return nil } - -type countingErrorWriter struct { - err error +type countingWriter struct { + bytes.Buffer writes int - last []byte } -func (w *countingErrorWriter) Write(p []byte) (int, error) { +func (w *countingWriter) Write(p []byte) (int, error) { w.writes++ - w.last = append([]byte(nil), p...) - return 0, w.err + return w.Buffer.Write(p) } - -var _ io.Writer = (*countingErrorWriter)(nil) From 3243367f84dbb1fa8cef36d705ac169f8701ed2e Mon Sep 17 00:00:00 2001 From: Ross Nelson Date: Fri, 24 Jul 2026 13:04:55 -0400 Subject: [PATCH 8/9] Fix Windows connection error command assertion --- internal/temporalcli/terminal_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/temporalcli/terminal_test.go b/internal/temporalcli/terminal_test.go index 1807c0be0..2a9327af9 100644 --- a/internal/temporalcli/terminal_test.go +++ b/internal/temporalcli/terminal_test.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "runtime" "strings" "testing" @@ -25,7 +26,11 @@ func TestWriteConnectionErrorHandlesWrappedConnectError(t *testing.T) { assert.Contains(t, stderr.String(), "Error: failed connecting to Temporal server at 127.0.0.1:7233: connection refused") assert.Contains(t, stderr.String(), "Namespace: default") assert.Contains(t, stderr.String(), "✗ TCP connection refused") - assert.Contains(t, stderr.String(), "temporal server start-dev") + expectedCommand := "temporal server start-dev" + if runtime.GOOS == "windows" { + expectedCommand = "& 'temporal' 'server' 'start-dev'" + } + assert.Contains(t, stderr.String(), expectedCommand) assert.NotContains(t, stderr.String(), "\x1b[") } From d4b68110fa3c96ab1a32b9b7dcf8c8007258742e Mon Sep 17 00:00:00 2001 From: Ross Nelson Date: Fri, 24 Jul 2026 13:22:42 -0400 Subject: [PATCH 9/9] Stabilize TLS cancellation diagnosis test --- internal/temporalcli/connectdiag_test.go | 48 ++++++++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/internal/temporalcli/connectdiag_test.go b/internal/temporalcli/connectdiag_test.go index 9fcb6ec73..736f3e9e8 100644 --- a/internal/temporalcli/connectdiag_test.go +++ b/internal/temporalcli/connectdiag_test.go @@ -235,27 +235,59 @@ func TestDiagnoseConnection_HealthyTLSFallsThroughToGRPC(t *testing.T) { func TestDiagnoseConnection_CancellationDuringTLSIsSkippedAndPrompt(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) - t.Cleanup(func() { _ = ln.Close() }) - accepted := make(chan struct{}) release := make(chan struct{}) + tlsStarted := make(chan error, 1) + serverDone := make(chan struct{}) go func() { + defer close(serverDone) conn, acceptErr := ln.Accept() if acceptErr != nil { + tlsStarted <- acceptErr return } defer conn.Close() - close(accepted) + header := make([]byte, 5) + _, readErr := io.ReadFull(conn, header) + tlsStarted <- readErr + if readErr != nil { + return + } <-release }() - t.Cleanup(func() { close(release) }) + t.Cleanup(func() { + close(release) + _ = ln.Close() + select { + case <-serverDone: + case <-time.After(time.Second): + t.Error("TLS test server did not stop") + } + }) ctx, cancel := context.WithCancel(t.Context()) + t.Cleanup(cancel) + diagnosisDone := make(chan *connectDiagnosis, 1) + started := time.Now() go func() { - <-accepted - cancel() + diagnosisDone <- diagnoseConnection(ctx, ln.Addr().String(), &tls.Config{InsecureSkipVerify: true}, errors.New("dial failed")) }() - started := time.Now() - d := diagnoseConnection(ctx, ln.Addr().String(), &tls.Config{InsecureSkipVerify: true}, errors.New("dial failed")) + + select { + case tlsErr := <-tlsStarted: + require.NoError(t, tlsErr) + case d := <-diagnosisDone: + t.Fatalf("diagnosis completed before TLS handshake began: %+v", d) + case <-time.After(time.Second): + t.Fatal("TLS handshake did not begin") + } + cancel() + + var d *connectDiagnosis + select { + case d = <-diagnosisDone: + case <-time.After(time.Second): + t.Fatal("diagnosis did not return after cancellation") + } assert.Less(t, time.Since(started), time.Second) assert.Equal(t, causeUnknown, d.Cause)