From c295b1c490dc14fc40936c76b6474c9c9756aee5 Mon Sep 17 00:00:00 2001 From: CLI Fix Bot Date: Thu, 28 May 2026 22:08:53 +0800 Subject: [PATCH] fix: prevent silent exits on error (#1139) and fix whiteboard API encoding (#1155) --- cmd/root.go | 10 +++++++- internal/client/encoding.go | 46 +++++++++++++++++++++++++++++++++++++ internal/client/response.go | 7 ++++-- internal/output/errors.go | 7 ++++++ main.go | 12 ++++++++++ 5 files changed, 79 insertions(+), 3 deletions(-) create mode 100644 internal/client/encoding.go diff --git a/cmd/root.go b/cmd/root.go index 5e6dd5dd0..231bad4c5 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -214,7 +214,15 @@ func configureFlagCompletions(args []string) { // per-domain typed migration in stage 2+. // 4. Cobra errors (required flags, unknown commands, etc.): plain text. func handleRootError(f *cmdutil.Factory, err error) int { - errOut := f.IOStreams.ErrOut + // Defensive: f or its IOStreams may be nil in exceptional situations + // (e.g. early bootstrap failure, plugin corruption). Guarantee stderr + // is always a valid writer so diagnostics are never swallowed. + var errOut io.Writer + if f != nil && f.IOStreams != nil && f.IOStreams.ErrOut != nil { + errOut = f.IOStreams.ErrOut + } else { + errOut = os.Stderr + } // SecurityPolicyError keeps the legacy custom envelope (string codes, // challenge_url, retryable) and exit code 1 — its wire shape predates the diff --git a/internal/client/encoding.go b/internal/client/encoding.go new file mode 100644 index 000000000..707298736 --- /dev/null +++ b/internal/client/encoding.go @@ -0,0 +1,46 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package client + +import ( + "bytes" + "unicode/utf8" + + "golang.org/x/text/encoding" + "golang.org/x/text/encoding/simplifiedchinese" + "golang.org/x/text/encoding/unicode" +) + +// autoDecodeBody attempts to detect and convert non-UTF-8 response bodies to +// valid UTF-8. Some Lark APIs (e.g. whiteboard) occasionally return JSON with +// Chinese characters in encodings other than UTF-8 despite the Content-Type +// header claiming otherwise. See https://github.com/larksuite/cli/issues/1155 +func autoDecodeBody(body []byte) []byte { + // Fast path: already valid UTF-8 (common case). + if utf8.Valid(body) { + return body + } + + // Strip UTF-8 BOM if present. + body = bytes.TrimPrefix(body, unicode.UTF8BOM) + + // Try encodings in order of likelihood for Lark/Feishu APIs. + candidates := []struct { + enc encoding.Encoding + name string + }{ + {simplifiedchinese.GBK, "GBK"}, + {simplifiedchinese.GB18030, "GB18030"}, + {simplifiedchinese.HZGB2312, "HZ-GB2312"}, + } + + for _, c := range candidates { + if decoded, err := c.enc.NewDecoder().Bytes(body); err == nil && utf8.Valid(decoded) { + return decoded + } + } + + // Return original unchanged if no conversion succeeded. + return body +} diff --git a/internal/client/response.go b/internal/client/response.go index 67ff98a0d..c12a46e1a 100644 --- a/internal/client/response.go +++ b/internal/client/response.go @@ -136,12 +136,15 @@ func IsJSONContentType(ct string) bool { // ParseJSONResponse decodes a raw SDK response body as JSON. // CallAPI and HandleResponse both delegate to this function. +// It automatically detects and converts non-UTF-8 encodings (e.g. GBK) that +// some APIs return despite claiming UTF-8 in the Content-Type header. func ParseJSONResponse(resp *larkcore.ApiResp) (interface{}, error) { + body := autoDecodeBody(resp.RawBody) var result interface{} - dec := json.NewDecoder(bytes.NewReader(resp.RawBody)) + dec := json.NewDecoder(bytes.NewReader(body)) dec.UseNumber() if err := dec.Decode(&result); err != nil { - return nil, fmt.Errorf("response parse error: %w (body: %s)", err, util.TruncateStr(string(resp.RawBody), 500)) + return nil, fmt.Errorf("response parse error: %w (body: %s)", err, util.TruncateStr(string(body), 500)) } return result, nil } diff --git a/internal/output/errors.go b/internal/output/errors.go index ee9caa95b..f7db4f9ac 100644 --- a/internal/output/errors.go +++ b/internal/output/errors.go @@ -72,6 +72,13 @@ func MarkRaw(err error) error { // to the typed surface. func WriteErrorEnvelope(w io.Writer, err *ExitError, identity string) { if err.Detail == nil { + // Even without structured detail, emit a minimal envelope so callers + // never see a completely empty stderr (https://github.com/larksuite/cli/issues/1139). + msg := err.Error() + if msg == "" { + msg = fmt.Sprintf("exit %d", err.Code) + } + fmt.Fprintf(w, `{"ok":false,"identity":%q,"error":{"type":"internal","message":%q}}`+"\n", identity, msg) return } env := &ErrorEnvelope{ diff --git a/main.go b/main.go index 02469bd7a..37c5cfd93 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,9 @@ package main import ( + "fmt" "os" + "runtime/debug" "github.com/larksuite/cli/cmd" @@ -13,5 +15,15 @@ import ( ) func main() { + // Recover from any unhandled panics to ensure stderr always receives + // diagnostics instead of crashing silently (especially in automation/ + // CI contexts where a panic without stack trace looks like exit 1 with + // empty stdout/stderr). See https://github.com/larksuite/cli/issues/1139 + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(os.Stderr, "panic: %v\n%s\n", r, debug.Stack()) + os.Exit(1) + } + }() os.Exit(cmd.Execute()) }