Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Comment on lines +217 to +225
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard all f dereferences in handleRootError to avoid nil panic paths.

The new fallback at Lines 217-225 is good, but f is still unconditionally dereferenced at Line 247 and Lines 256-259. If f is nil in the exceptional path this block targets, handleRootError can still panic.

Proposed fix
 func handleRootError(f *cmdutil.Factory, err error) int {
@@
 	var errOut io.Writer
 	if f != nil && f.IOStreams != nil && f.IOStreams.ErrOut != nil {
 		errOut = f.IOStreams.ErrOut
 	} else {
 		errOut = os.Stderr
 	}
+	identity := ""
+	if f != nil {
+		identity = string(f.ResolvedIdentity)
+	}
@@
-	applyNeedAuthorizationHint(f, err)
+	if f != nil {
+		applyNeedAuthorizationHint(f, err)
+	}
 
-	if output.WriteTypedErrorEnvelope(errOut, err, string(f.ResolvedIdentity)) {
+	if output.WriteTypedErrorEnvelope(errOut, err, identity) {
 		return output.ExitCodeOf(err)
 	}
@@
 	if exitErr := asExitError(err); exitErr != nil {
-		if !exitErr.Raw {
+		if !exitErr.Raw && f != nil {
 			// Raw errors (e.g. from `api` command via output.MarkRaw)
 			// preserve the original API error detail; skip enrichment
 			// which would clear it.
 			enrichMissingScopeError(f, exitErr)
 			enrichPermissionError(f, exitErr)
 		}
-		output.WriteErrorEnvelope(errOut, exitErr, string(f.ResolvedIdentity))
+		output.WriteErrorEnvelope(errOut, exitErr, identity)
 		return exitErr.Code
 	}

Also applies to: 245-247, 256-259

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@cmd/root.go` around lines 217 - 225, In handleRootError, guard every
dereference of f: compute errOut as shown and then replace all direct uses of
f.IOStreams.ErrOut with errOut, and wrap any access to f (e.g., f.Name,
f.CommandPath, or other fields) in nil checks (if f != nil { ... } else { use
sensible fallbacks or omit values) so the function never dereferences f when
it's nil; specifically update the code paths that currently reference f at the
later return/log/formatting sites to use errOut and conditional checks around f
before reading any of its fields.


// SecurityPolicyError keeps the legacy custom envelope (string codes,
// challenge_url, retryable) and exit code 1 — its wire shape predates the
Expand Down
46 changes: 46 additions & 0 deletions internal/client/encoding.go
Original file line number Diff line number Diff line change
@@ -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
}
7 changes: 5 additions & 2 deletions internal/client/response.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
7 changes: 7 additions & 0 deletions internal/output/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
12 changes: 12 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,25 @@
package main

import (
"fmt"
"os"
"runtime/debug"

"github.com/larksuite/cli/cmd"

_ "github.com/larksuite/cli/extension/credential/env" // activate env credential provider
)

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())
}
Loading