chore: import upstream snapshot with attribution
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import "fmt"
|
||||
|
||||
// BareError is the silent-exit signal for commands whose stdout already
|
||||
// carries the complete answer and that only need the matching exit code
|
||||
// without a stderr envelope. Two cases use it: a predicate writing its yes/no
|
||||
// JSON (e.g. `auth check` exiting non-zero on a no-token state), and a command
|
||||
// emitting its own structured result envelope under `--json` (e.g. `update`).
|
||||
// Deliberately outside the typed-envelope contract.
|
||||
type BareError struct{ Code int }
|
||||
|
||||
func (e *BareError) Error() string { return fmt.Sprintf("bare exit %d", e.Code) }
|
||||
|
||||
// ErrBare builds the silent-exit signal with the given code.
|
||||
func ErrBare(code int) *BareError { return &BareError{Code: code} }
|
||||
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
func TestExitCodeOfBareError(t *testing.T) {
|
||||
if got := output.ExitCodeOf(output.ErrBare(3)); got != 3 {
|
||||
t.Errorf("ExitCodeOf(ErrBare(3)) = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestErrBareReturnsBareError pins that the silent-exit signal is the
|
||||
// dedicated *output.BareError type, keeping that contract on its own
|
||||
// narrow signal type.
|
||||
func TestErrBareReturnsBareError(t *testing.T) {
|
||||
var _ *output.BareError = output.ErrBare(1)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
const (
|
||||
Dim = "\033[2m"
|
||||
Bold = "\033[1m"
|
||||
Yellow = "\033[33m"
|
||||
Cyan = "\033[36m"
|
||||
Red = "\033[31m"
|
||||
Green = "\033[32m"
|
||||
Reset = "\033[0m"
|
||||
)
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// FormatAsCSV formats data as CSV (with header) and writes it to w.
|
||||
func FormatAsCSV(w io.Writer, data interface{}) {
|
||||
FormatAsCSVPaginated(w, data, true)
|
||||
}
|
||||
|
||||
// FormatAsCSVPaginated formats data as CSV with pagination awareness.
|
||||
// When isFirstPage is true, outputs the header row; otherwise only data rows.
|
||||
func FormatAsCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) {
|
||||
rows, cols, isList := prepareRows(data)
|
||||
if cols == nil {
|
||||
if isList {
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
} else {
|
||||
PrintJson(w, data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
if isFirstPage {
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !isList {
|
||||
// Single object: key,value rows
|
||||
cw := csv.NewWriter(w)
|
||||
if isFirstPage {
|
||||
cw.Write([]string{"key", "value"})
|
||||
}
|
||||
for _, col := range cols {
|
||||
cw.Write([]string{col, rows[0][col]})
|
||||
}
|
||||
flushCSV(cw)
|
||||
return
|
||||
}
|
||||
|
||||
writeCSVRows(w, rows, cols, isFirstPage)
|
||||
}
|
||||
|
||||
// writeCSVRows writes CSV data rows (and optionally header) using the given columns.
|
||||
func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) {
|
||||
cw := csv.NewWriter(w)
|
||||
if writeHeader {
|
||||
cw.Write(cols)
|
||||
}
|
||||
for _, row := range rows {
|
||||
record := make([]string, len(cols))
|
||||
for i, col := range cols {
|
||||
record[i] = row[col]
|
||||
}
|
||||
cw.Write(record)
|
||||
}
|
||||
flushCSV(cw)
|
||||
}
|
||||
|
||||
// flushCSV flushes the csv.Writer and reports any write error to stderr.
|
||||
func flushCSV(cw *csv.Writer) {
|
||||
cw.Flush()
|
||||
if err := cw.Error(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "csv write error: %v\n", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatAsCSV_BasicArray(t *testing.T) {
|
||||
data := []interface{}{
|
||||
map[string]interface{}{"name": "Alice", "age": float64(30)},
|
||||
map[string]interface{}{"name": "Bob", "age": float64(25)},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsCSV(&buf, data)
|
||||
out := buf.String()
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("expected 3 lines (header + 2 rows), got %d:\n%s", len(lines), out)
|
||||
}
|
||||
|
||||
// Header should contain both column names
|
||||
header := lines[0]
|
||||
if !strings.Contains(header, "name") || !strings.Contains(header, "age") {
|
||||
t.Errorf("header should contain 'name' and 'age', got: %s", header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsCSV_RFC4180Escaping(t *testing.T) {
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"text": `hello, "world"`,
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsCSV(&buf, data)
|
||||
out := buf.String()
|
||||
|
||||
// RFC 4180: fields with commas/quotes are quoted, internal quotes are doubled
|
||||
if !strings.Contains(out, `"hello, ""world"""`) {
|
||||
t.Errorf("CSV should properly escape commas and quotes, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsCSV_NewlineInValue(t *testing.T) {
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"text": "line1\nline2",
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsCSV(&buf, data)
|
||||
out := buf.String()
|
||||
|
||||
// RFC 4180: fields with newlines should be quoted
|
||||
if !strings.Contains(out, `"line1`) {
|
||||
t.Errorf("CSV should quote fields containing newlines, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsCSV_NestedObject(t *testing.T) {
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "Alice",
|
||||
},
|
||||
"id": float64(1),
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsCSV(&buf, data)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "user.name") {
|
||||
t.Errorf("CSV should contain flattened 'user.name' column, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsCSV_EmptyArray(t *testing.T) {
|
||||
data := []interface{}{}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsCSV(&buf, data)
|
||||
out := strings.TrimSpace(buf.String())
|
||||
|
||||
if out != "(empty)" {
|
||||
t.Errorf("empty array should output '(empty)', got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsCSVPaginated_FirstPage(t *testing.T) {
|
||||
data := []interface{}{
|
||||
map[string]interface{}{"name": "Alice"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsCSVPaginated(&buf, data, true)
|
||||
out := buf.String()
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
|
||||
if len(lines) != 2 {
|
||||
t.Errorf("first page should have header + 1 data row, got %d lines:\n%s", len(lines), out)
|
||||
}
|
||||
if lines[0] != "name" {
|
||||
t.Errorf("first line should be header 'name', got: %s", lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsCSVPaginated_ContinuationPage(t *testing.T) {
|
||||
data := []interface{}{
|
||||
map[string]interface{}{"name": "Bob"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsCSVPaginated(&buf, data, false)
|
||||
out := buf.String()
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
|
||||
if len(lines) != 1 {
|
||||
t.Errorf("continuation page should have 1 data row, got %d lines:\n%s", len(lines), out)
|
||||
}
|
||||
if lines[0] != "Bob" {
|
||||
t.Errorf("continuation page data should be 'Bob', got: %s", lines[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsCSV_SingleObject(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"name": "Alice",
|
||||
"age": float64(30),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsCSV(&buf, data)
|
||||
out := buf.String()
|
||||
|
||||
// Single object should render as key,value format
|
||||
if !strings.Contains(out, "key,value") {
|
||||
t.Errorf("single object should have key,value header, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Alice") {
|
||||
t.Errorf("output should contain 'Alice', got:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
)
|
||||
|
||||
// ScanResult holds the output of ScanForSafety.
|
||||
type ScanResult struct {
|
||||
Alert *extcs.Alert
|
||||
Blocked bool
|
||||
BlockErr error
|
||||
}
|
||||
|
||||
// ScanForSafety runs content-safety scanning on the given data.
|
||||
// cmdPath is the raw cobra CommandPath().
|
||||
// When MODE=off, no provider registered, or the command is not allowlisted,
|
||||
// returns a zero ScanResult.
|
||||
func ScanForSafety(cmdPath string, data any, errOut io.Writer) ScanResult {
|
||||
alert, csErr := runContentSafety(cmdPath, data, errOut)
|
||||
if errors.Is(csErr, errBlocked) {
|
||||
return ScanResult{
|
||||
Alert: alert,
|
||||
Blocked: true,
|
||||
BlockErr: wrapBlockError(alert),
|
||||
}
|
||||
}
|
||||
return ScanResult{Alert: alert}
|
||||
}
|
||||
|
||||
// wrapBlockError creates a typed error for content-safety block.
|
||||
func wrapBlockError(alert *extcs.Alert) error {
|
||||
var matchedRules []string
|
||||
if alert != nil {
|
||||
matchedRules = alert.MatchedRules
|
||||
}
|
||||
return errs.NewContentSafetyError(errs.SubtypeContentSafety,
|
||||
"content safety violation detected (rules: %s)", strings.Join(matchedRules, ", ")).
|
||||
WithRules(matchedRules...).
|
||||
WithCause(errBlocked)
|
||||
}
|
||||
|
||||
// WriteAlertWarning writes a human-readable content-safety warning to w.
|
||||
// Used by non-JSON output paths (pretty, table, csv) in warn mode.
|
||||
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) {
|
||||
if alert == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
|
||||
alert.Provider, strings.Join(alert.MatchedRules, ", "))
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/envvars"
|
||||
)
|
||||
|
||||
type mode uint8
|
||||
|
||||
const (
|
||||
modeOff mode = iota
|
||||
modeWarn
|
||||
modeBlock
|
||||
)
|
||||
|
||||
// scanTimeout caps the content-safety scan so it cannot dominate CLI latency.
|
||||
// 100 ms is generous for a regex walk of a typical API response (KB-scale JSON);
|
||||
// larger responses hit maxDepth/maxStringBytes well before this fires.
|
||||
const scanTimeout = 100 * time.Millisecond
|
||||
|
||||
// modeFromEnv reads LARKSUITE_CLI_CONTENT_SAFETY_MODE.
|
||||
func modeFromEnv(errOut io.Writer) mode {
|
||||
raw := strings.TrimSpace(os.Getenv(envvars.CliContentSafetyMode))
|
||||
if raw == "" {
|
||||
return modeOff
|
||||
}
|
||||
switch strings.ToLower(raw) {
|
||||
case "off":
|
||||
return modeOff
|
||||
case "warn":
|
||||
return modeWarn
|
||||
case "block":
|
||||
return modeBlock
|
||||
default:
|
||||
fmt.Fprintf(errOut,
|
||||
"warning: unknown %s value %q, falling back to off\n",
|
||||
envvars.CliContentSafetyMode, raw)
|
||||
return modeOff
|
||||
}
|
||||
}
|
||||
|
||||
// normalizeCommandPath converts cobra CommandPath() to dotted form.
|
||||
// "lark-cli im +messages-search" -> "im.messages_search"
|
||||
func normalizeCommandPath(cobraPath string) string {
|
||||
segs := strings.Fields(cobraPath)
|
||||
if len(segs) <= 1 {
|
||||
return ""
|
||||
}
|
||||
segs = segs[1:]
|
||||
for i, s := range segs {
|
||||
s = strings.TrimPrefix(s, "+")
|
||||
s = strings.ReplaceAll(s, "-", "_")
|
||||
segs[i] = s
|
||||
}
|
||||
return strings.Join(segs, ".")
|
||||
}
|
||||
|
||||
var errBlocked = fmt.Errorf("content safety blocked")
|
||||
|
||||
// runContentSafety orchestrates the scan: mode check -> provider -> scan with timeout + panic recovery.
|
||||
func runContentSafety(cobraPath string, data any, errOut io.Writer) (*extcs.Alert, error) {
|
||||
m := modeFromEnv(errOut)
|
||||
if m == modeOff {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
p := extcs.GetProvider()
|
||||
if p == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
cmdPath := normalizeCommandPath(cobraPath)
|
||||
if cmdPath == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type result struct {
|
||||
alert *extcs.Alert
|
||||
err error
|
||||
}
|
||||
ch := make(chan result, 1)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), scanTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Give the goroutine its own writer so it cannot race on errOut after timeout.
|
||||
// On success, we copy any provider notices to the real errOut.
|
||||
// On timeout, the buffer is owned by the goroutine until it finishes; no shared access.
|
||||
scanErrBuf := &bytes.Buffer{}
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
ch <- result{nil, fmt.Errorf("content safety panic: %v", r)}
|
||||
}
|
||||
}()
|
||||
a, e := p.Scan(ctx, extcs.ScanRequest{Path: cmdPath, Data: data, ErrOut: scanErrBuf})
|
||||
ch <- result{a, e}
|
||||
}()
|
||||
|
||||
var res result
|
||||
select {
|
||||
case res = <-ch:
|
||||
if scanErrBuf.Len() > 0 {
|
||||
_, _ = io.Copy(errOut, scanErrBuf)
|
||||
}
|
||||
case <-ctx.Done():
|
||||
return nil, nil // timeout, fail-open; scanErrBuf stays with the goroutine
|
||||
}
|
||||
|
||||
if res.err != nil {
|
||||
fmt.Fprintf(errOut, "warning: content safety scan error: %v\n", res.err)
|
||||
return nil, nil // fail-open
|
||||
}
|
||||
if res.alert == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if m == modeBlock {
|
||||
return res.alert, errBlocked
|
||||
}
|
||||
return res.alert, nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestModeFromEnv(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
envVal string
|
||||
want mode
|
||||
wantWarn bool
|
||||
}{
|
||||
{"empty", "", modeOff, false},
|
||||
{"off", "off", modeOff, false},
|
||||
{"OFF", "OFF", modeOff, false},
|
||||
{"warn", "warn", modeWarn, false},
|
||||
{"WARN", "WARN", modeWarn, false},
|
||||
{"block", "block", modeBlock, false},
|
||||
{"unknown", "banana", modeOff, true},
|
||||
{"whitespace", " warn ", modeWarn, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", tt.envVal)
|
||||
var buf bytes.Buffer
|
||||
got := modeFromEnv(&buf)
|
||||
if got != tt.want {
|
||||
t.Errorf("modeFromEnv() = %d, want %d", got, tt.want)
|
||||
}
|
||||
if tt.wantWarn && buf.Len() == 0 {
|
||||
t.Error("expected stderr warning")
|
||||
}
|
||||
if !tt.wantWarn && buf.Len() > 0 {
|
||||
t.Errorf("unexpected stderr: %s", buf.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCommandPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"lark-cli im +messages-search", "im.messages_search"},
|
||||
{"lark-cli drive upload +file", "drive.upload.file"},
|
||||
{"lark-cli api GET /path", "api.GET./path"},
|
||||
{"lark-cli", ""},
|
||||
{"", ""},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := normalizeCommandPath(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("normalizeCommandPath(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
)
|
||||
|
||||
// mockProvider is a test provider that returns a configurable alert.
|
||||
type mockProvider struct {
|
||||
name string
|
||||
alert *extcs.Alert
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockProvider) Name() string { return m.name }
|
||||
func (m *mockProvider) Scan(_ context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return m.alert, m.err
|
||||
}
|
||||
|
||||
func TestScanForSafety_ModeOff(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
var buf bytes.Buffer
|
||||
result := ScanForSafety("lark-cli im +messages-search", map[string]any{"text": "inject"}, &buf)
|
||||
if result.Alert != nil || result.Blocked {
|
||||
t.Error("mode=off should produce zero ScanResult")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanForSafety_ModeWarn_WithAlert(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
alert := &extcs.Alert{Provider: "mock", MatchedRules: []string{"r1"}}
|
||||
mp := &mockProvider{name: "mock", alert: alert}
|
||||
|
||||
// Register mock provider (save and restore)
|
||||
extcs.Register(mp)
|
||||
defer extcs.Register(nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
|
||||
if result.Alert == nil {
|
||||
t.Fatal("expected non-nil alert in warn mode")
|
||||
}
|
||||
if result.Blocked {
|
||||
t.Error("warn mode should not block")
|
||||
}
|
||||
if result.BlockErr != nil {
|
||||
t.Error("warn mode should not have BlockErr")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanForSafety_ModeBlock_WithAlert(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
alert := &extcs.Alert{Provider: "mock", MatchedRules: []string{"r1"}}
|
||||
mp := &mockProvider{name: "mock", alert: alert}
|
||||
extcs.Register(mp)
|
||||
defer extcs.Register(nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
|
||||
if !result.Blocked {
|
||||
t.Error("block mode with alert should set Blocked=true")
|
||||
}
|
||||
if result.BlockErr == nil {
|
||||
t.Error("block mode with alert should have BlockErr")
|
||||
}
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(result.BlockErr, &safetyErr) {
|
||||
t.Fatalf("BlockErr should be *ContentSafetyError, got %T", result.BlockErr)
|
||||
}
|
||||
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
|
||||
t.Errorf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
|
||||
}
|
||||
if got := ExitCodeOf(result.BlockErr); got != ExitContentSafety {
|
||||
t.Errorf("exit code = %d, want %d", got, ExitContentSafety)
|
||||
}
|
||||
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "r1" {
|
||||
t.Errorf("rules = %v, want [r1]", safetyErr.Rules)
|
||||
}
|
||||
if !errors.Is(result.BlockErr, errBlocked) {
|
||||
t.Error("BlockErr should preserve errBlocked cause")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanForSafety_NoProvider(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
|
||||
if result.Alert != nil || result.Blocked {
|
||||
t.Error("no provider should produce zero ScanResult")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanForSafety_ScanError_FailOpen(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
mp := &mockProvider{name: "mock", err: errors.New("scan broke")}
|
||||
extcs.Register(mp)
|
||||
defer extcs.Register(nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
|
||||
if result.Blocked {
|
||||
t.Error("scan error should fail-open, not block")
|
||||
}
|
||||
if !strings.Contains(buf.String(), "scan error") {
|
||||
t.Errorf("expected warning on stderr, got: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanForSafety_SlowProvider_Timeout_FailOpen(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
|
||||
slow := &slowProvider{}
|
||||
extcs.Register(slow)
|
||||
defer extcs.Register(nil)
|
||||
|
||||
var buf bytes.Buffer
|
||||
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
|
||||
if result.Blocked {
|
||||
t.Error("slow provider should fail-open on timeout, not block")
|
||||
}
|
||||
if result.Alert != nil {
|
||||
t.Error("slow provider should return nil alert on timeout")
|
||||
}
|
||||
}
|
||||
|
||||
// slowProvider blocks for longer than scanTimeout to trigger the timeout path.
|
||||
type slowProvider struct{}
|
||||
|
||||
func (s *slowProvider) Name() string { return "slow" }
|
||||
func (s *slowProvider) Scan(ctx context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
return &extcs.Alert{Provider: "slow", MatchedRules: []string{"never"}}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteAlertWarning(t *testing.T) {
|
||||
alert := &extcs.Alert{Provider: "regex", MatchedRules: []string{"r1", "r2"}}
|
||||
var buf bytes.Buffer
|
||||
WriteAlertWarning(&buf, alert)
|
||||
got := buf.String()
|
||||
if !strings.Contains(got, "r1") || !strings.Contains(got, "r2") {
|
||||
t.Errorf("warning should contain rule IDs, got: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
// Envelope is the standard success response wrapper.
|
||||
type Envelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Meta *Meta `json:"meta,omitempty"`
|
||||
ContentSafetyAlert interface{} `json:"_content_safety_alert,omitempty"`
|
||||
Notice map[string]interface{} `json:"_notice,omitempty"`
|
||||
}
|
||||
|
||||
// Meta carries optional metadata in envelope responses.
|
||||
type Meta struct {
|
||||
Count int `json:"count,omitempty"`
|
||||
Rollback string `json:"rollback,omitempty"`
|
||||
}
|
||||
|
||||
// PendingNotice, if set, returns system-level notices to inject as the
|
||||
// "_notice" field in JSON output envelopes. Set by cmd/root.go.
|
||||
// Returns nil when there is nothing to report.
|
||||
var PendingNotice func() map[string]interface{}
|
||||
|
||||
// GetNotice returns the current pending notice for struct-based callers.
|
||||
// Returns nil when there is nothing to report.
|
||||
func GetNotice() map[string]interface{} {
|
||||
if PendingNotice == nil {
|
||||
return nil
|
||||
}
|
||||
return PendingNotice()
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import "io"
|
||||
|
||||
// SuccessEnvelopeOptions configures the shortcut-compatible success envelope.
|
||||
type SuccessEnvelopeOptions struct {
|
||||
CommandPath string
|
||||
Identity string
|
||||
JqExpr string
|
||||
Out io.Writer
|
||||
ErrOut io.Writer
|
||||
}
|
||||
|
||||
// SuccessEnvelopeData extracts the business payload for the standard success
|
||||
// envelope from a Lark API response. Outer code/msg fields are transport
|
||||
// protocol details and are intentionally not exposed as business data.
|
||||
func SuccessEnvelopeData(result interface{}) interface{} {
|
||||
m, ok := result.(map[string]interface{})
|
||||
if !ok {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
data, ok := m["data"]
|
||||
if !ok || data == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
// WriteSuccessEnvelope emits the standard success envelope used by shortcuts.
|
||||
// JSON output carries content-safety alerts inside the envelope. When jq is
|
||||
// applied, the alert may be filtered away, so warn mode also writes stderr.
|
||||
func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
|
||||
scanResult := ScanForSafety(opts.CommandPath, data, opts.ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
|
||||
env := Envelope{
|
||||
OK: true,
|
||||
Identity: opts.Identity,
|
||||
Data: data,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
env.ContentSafetyAlert = scanResult.Alert
|
||||
}
|
||||
if opts.JqExpr != "" {
|
||||
if scanResult.Alert != nil && opts.ErrOut != nil {
|
||||
WriteAlertWarning(opts.ErrOut, scanResult.Alert)
|
||||
}
|
||||
return JqFilter(opts.Out, env, opts.JqExpr)
|
||||
}
|
||||
PrintJson(opts.Out, env)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
)
|
||||
|
||||
func TestSuccessEnvelopeData_ExtractsBusinessData(t *testing.T) {
|
||||
result := map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{"id": "1"},
|
||||
}
|
||||
|
||||
got := SuccessEnvelopeData(result)
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("business data type = %T, want map", got)
|
||||
}
|
||||
if m["id"] != "1" {
|
||||
t.Fatalf("id = %v, want 1", m["id"])
|
||||
}
|
||||
if _, ok := m["code"]; ok {
|
||||
t.Fatal("business data must not contain outer code")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessEnvelopeData_MissingDataUsesEmptyObject(t *testing.T) {
|
||||
got := SuccessEnvelopeData(map[string]interface{}{"code": float64(0), "msg": "ok"})
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("business data type = %T, want map", got)
|
||||
}
|
||||
if len(m) != 0 {
|
||||
t.Fatalf("business data = %#v, want empty object", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuccessEnvelopeData_NilDataUsesEmptyObject(t *testing.T) {
|
||||
got := SuccessEnvelopeData(map[string]interface{}{"code": float64(0), "msg": "ok", "data": nil})
|
||||
m, ok := got.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("business data type = %T, want map", got)
|
||||
}
|
||||
if len(m) != 0 {
|
||||
t.Fatalf("business data = %#v, want empty object", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_PrintsShortcutCompatibleEnvelope(t *testing.T) {
|
||||
var out strings.Builder
|
||||
|
||||
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
|
||||
Identity: "bot",
|
||||
Out: &out,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
||||
}
|
||||
|
||||
var env map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out.String()), &env); err != nil {
|
||||
t.Fatalf("invalid JSON output: %v\n%s", err, out.String())
|
||||
}
|
||||
if env["ok"] != true || env["identity"] != "bot" {
|
||||
t.Fatalf("unexpected envelope: %#v", env)
|
||||
}
|
||||
data, ok := env["data"].(map[string]interface{})
|
||||
if !ok || data["id"] != "1" {
|
||||
t.Fatalf("unexpected data payload: %#v", env["data"])
|
||||
}
|
||||
if _, ok := env["code"]; ok {
|
||||
t.Fatalf("output leaked protocol field code: %#v", env)
|
||||
}
|
||||
if _, ok := env["msg"]; ok {
|
||||
t.Fatalf("output leaked protocol field msg: %#v", env)
|
||||
}
|
||||
if _, ok := env["_content_safety_alert"]; ok {
|
||||
t.Fatalf("output should omit empty content-safety alert: %#v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_JqUsesEnvelope(t *testing.T) {
|
||||
var out strings.Builder
|
||||
|
||||
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
|
||||
Identity: "bot",
|
||||
JqExpr: ".data.id",
|
||||
Out: &out,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out.String()) != "1" {
|
||||
t.Fatalf("jq output = %q, want %q", out.String(), "1")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_JqWarnsWhenSafetyAlertFiltered(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&mockProvider{
|
||||
name: "mock",
|
||||
alert: &extcs.Alert{Provider: "mock", MatchedRules: []string{"r1"}},
|
||||
})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
var out strings.Builder
|
||||
var errOut strings.Builder
|
||||
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
|
||||
CommandPath: "lark-cli im +test",
|
||||
Identity: "bot",
|
||||
JqExpr: ".data.id",
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WriteSuccessEnvelope() error = %v", err)
|
||||
}
|
||||
if strings.TrimSpace(out.String()) != "1" {
|
||||
t.Fatalf("jq output = %q, want %q", out.String(), "1")
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "warning: content safety alert from mock") {
|
||||
t.Fatalf("expected content safety warning on stderr, got: %s", errOut.String())
|
||||
}
|
||||
if !strings.Contains(errOut.String(), "r1") {
|
||||
t.Fatalf("expected rule in stderr warning, got: %s", errOut.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteSuccessEnvelope_BlockModeReturnsTypedErrorWithoutStdout(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
|
||||
extcs.Register(&mockProvider{
|
||||
name: "mock",
|
||||
alert: &extcs.Alert{Provider: "mock", MatchedRules: []string{"r1"}},
|
||||
})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
var out strings.Builder
|
||||
var errOut strings.Builder
|
||||
err := WriteSuccessEnvelope(map[string]interface{}{"id": "1"}, SuccessEnvelopeOptions{
|
||||
CommandPath: "lark-cli im +test",
|
||||
Identity: "bot",
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected content safety block error")
|
||||
}
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(err, &safetyErr) {
|
||||
t.Fatalf("expected ContentSafetyError, got %T: %v", err, err)
|
||||
}
|
||||
if safetyErr.Category != errs.CategoryPolicy || safetyErr.Subtype != errs.SubtypeContentSafety {
|
||||
t.Fatalf("problem = %s/%s, want %s/%s", safetyErr.Category, safetyErr.Subtype, errs.CategoryPolicy, errs.SubtypeContentSafety)
|
||||
}
|
||||
if len(safetyErr.Rules) != 1 || safetyErr.Rules[0] != "r1" {
|
||||
t.Fatalf("rules = %v, want [r1]", safetyErr.Rules)
|
||||
}
|
||||
if !errors.Is(err, errBlocked) {
|
||||
t.Fatal("content safety error should preserve errBlocked cause")
|
||||
}
|
||||
if out.String() != "" {
|
||||
t.Fatalf("stdout should stay empty on block, got: %s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// PartialFailureError is the exit signal for a batch / multi-status command that
|
||||
// has already written an ok:false result envelope to stdout. The per-item
|
||||
// outcomes are the primary, machine-readable output and live on stdout, so the
|
||||
// dispatcher sets only the exit code and writes nothing to stderr.
|
||||
//
|
||||
// It is deliberately distinct from ErrBare (the stdout-carries-the-answer
|
||||
// silent-exit signal) so that contract stays narrow, and from a typed *errs.XxxError
|
||||
// (which owns the stderr error envelope): a partial failure is a result, not an
|
||||
// error envelope.
|
||||
type PartialFailureError struct {
|
||||
Code int
|
||||
}
|
||||
|
||||
func (e *PartialFailureError) Error() string {
|
||||
return fmt.Sprintf("partial failure (exit %d)", e.Code)
|
||||
}
|
||||
|
||||
// PartialFailure builds the partial-failure exit signal with the given code.
|
||||
func PartialFailure(code int) *PartialFailureError {
|
||||
return &PartialFailureError{Code: code}
|
||||
}
|
||||
|
||||
// WriteTypedErrorEnvelope writes the JSON error envelope for a typed error.
|
||||
// Each typed error owns its wire shape via its own struct tags: Problem fields
|
||||
// are promoted to the top level through embedding, and extension fields
|
||||
// (MissingScopes, ChallengeURL, etc.) sit alongside as siblings — not inside
|
||||
// a `detail` sub-object.
|
||||
//
|
||||
// Two-stage write:
|
||||
//
|
||||
// 1. Serialize the envelope into an in-memory buffer. If serialization
|
||||
// fails, return false so the dispatcher handles it via its signal /
|
||||
// usage-error branches; nothing is written to w.
|
||||
// 2. Best-effort write of the serialized bytes to w. A partial write is
|
||||
// accepted (return value still true): the typed exit code has already
|
||||
// been determined upstream by handleRootError calling ExitCodeOf(err)
|
||||
// before this writer runs, so a torn envelope on stderr must not
|
||||
// downgrade the caller's typed exit (3/4/6/10) to plain 1. Consumers
|
||||
// parse-or-skip on malformed JSON.
|
||||
//
|
||||
// Returns true when err was a typed error and serialization succeeded.
|
||||
// Returns false only when err carries no Problem (the dispatcher then handles
|
||||
// it via its signal / usage-error branches) or when JSON encoding itself failed.
|
||||
func WriteTypedErrorEnvelope(w io.Writer, err error, identity string) bool {
|
||||
typed, ok := errs.UnwrapTypedError(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
env := typedEnvelope{
|
||||
OK: false,
|
||||
Identity: identity,
|
||||
Error: typed,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
enc := json.NewEncoder(&buf)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if encErr := enc.Encode(env); encErr != nil {
|
||||
// Encoding failed — emit nothing here; the dispatcher's fall-through
|
||||
// branches still surface the error, so stderr is never blank.
|
||||
return false
|
||||
}
|
||||
// Best-effort write. Partial-write does not downgrade the success status:
|
||||
// the dispatcher has already captured ExitCodeOf(err) before calling us,
|
||||
// and a torn stderr is preferable to falling through to the plain
|
||||
// "Error:" path with exit 1.
|
||||
_, _ = w.Write(buf.Bytes())
|
||||
return true
|
||||
}
|
||||
|
||||
// typedEnvelope wraps a typed error for wire emission. Error is `error` so the
|
||||
// underlying typed error's own json tags determine the inner shape via
|
||||
// encoding/json reflection; Notice mirrors the success Envelope's notice (see
|
||||
// GetNotice in envelope.go).
|
||||
type typedEnvelope struct {
|
||||
OK bool `json:"ok"`
|
||||
Identity string `json:"identity,omitempty"`
|
||||
Error error `json:"error"`
|
||||
Notice map[string]interface{} `json:"_notice,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// failingWriter writes up to limit bytes then returns io.ErrShortWrite on
|
||||
// the write that would push past the limit. Used to simulate a stderr that
|
||||
// dies mid-envelope.
|
||||
type failingWriter struct {
|
||||
limit int
|
||||
n int
|
||||
}
|
||||
|
||||
func (f *failingWriter) Write(p []byte) (int, error) {
|
||||
if f.n+len(p) > f.limit {
|
||||
canWrite := f.limit - f.n
|
||||
if canWrite < 0 {
|
||||
canWrite = 0
|
||||
}
|
||||
f.n += canWrite
|
||||
return canWrite, io.ErrShortWrite
|
||||
}
|
||||
f.n += len(p)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus pins that
|
||||
// when serialization succeeds but the underlying write fails mid-envelope,
|
||||
// WriteTypedErrorEnvelope returns true so the dispatcher honors the typed
|
||||
// exit code instead of reclassifying the error. Exit code is preserved
|
||||
// separately by handleRootError computing ExitCodeOf(err) before the write.
|
||||
func TestWriteTypedErrorEnvelope_PartialWritePreservesSuccessStatus(t *testing.T) {
|
||||
err := errs.NewAuthenticationError(errs.SubtypeTokenExpired, "token expired")
|
||||
w := &failingWriter{limit: 20} // dies mid-envelope
|
||||
if ok := WriteTypedErrorEnvelope(w, err, "user"); !ok {
|
||||
t.Error("partial write must return true; exit code is preserved separately")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetNotice(t *testing.T) {
|
||||
// Nil PendingNotice → nil
|
||||
origNotice := PendingNotice
|
||||
PendingNotice = nil
|
||||
if got := GetNotice(); got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
|
||||
// With PendingNotice → returns value
|
||||
PendingNotice = func() map[string]interface{} {
|
||||
return map[string]interface{}{"update": "test"}
|
||||
}
|
||||
got := GetNotice()
|
||||
if got == nil || got["update"] != "test" {
|
||||
t.Errorf("expected {update: test}, got %v", got)
|
||||
}
|
||||
|
||||
// PendingNotice returns nil → nil
|
||||
PendingNotice = func() map[string]interface{} { return nil }
|
||||
if got := GetNotice(); got != nil {
|
||||
t.Errorf("expected nil, got %v", got)
|
||||
}
|
||||
|
||||
PendingNotice = origNotice
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// Fine-grained error types (permission, not_found, rate_limit, etc.)
|
||||
// are communicated via the JSON error envelope's "type" field,
|
||||
// not via exit codes.
|
||||
const (
|
||||
ExitOK = 0 // 成功
|
||||
ExitAPI = 1 // API / 通用错误(含 permission、not_found、conflict、rate_limit)
|
||||
ExitValidation = 2 // 参数校验失败
|
||||
ExitAuth = 3 // 认证失败(token 无效 / 过期),或登录成功但请求 scopes 未全部授予
|
||||
ExitNetwork = 4 // 网络错误(连接超时、DNS 解析失败等)
|
||||
ExitInternal = 5 // 内部错误(不应发生)
|
||||
ExitContentSafety = 6 // content safety violation (block mode)
|
||||
ExitConfirmationRequired = 10 // 高风险操作需要 --yes 确认(agent 协议信号)
|
||||
)
|
||||
|
||||
// ExitCodeForCategory maps an errs.Category to the shell exit code.
|
||||
// Multiple categories may share an exit code (Authentication / Authorization /
|
||||
// Config all map to 3), so the relationship is many-to-one.
|
||||
func ExitCodeForCategory(cat errs.Category) int {
|
||||
switch cat {
|
||||
case errs.CategoryValidation:
|
||||
return ExitValidation
|
||||
case errs.CategoryAuthentication, errs.CategoryAuthorization, errs.CategoryConfig:
|
||||
return ExitAuth
|
||||
case errs.CategoryNetwork:
|
||||
return ExitNetwork
|
||||
case errs.CategoryAPI:
|
||||
return ExitAPI
|
||||
case errs.CategoryPolicy:
|
||||
return ExitContentSafety
|
||||
case errs.CategoryInternal:
|
||||
return ExitInternal
|
||||
case errs.CategoryConfirmation:
|
||||
return ExitConfirmationRequired
|
||||
}
|
||||
return ExitInternal
|
||||
}
|
||||
|
||||
// ExitCodeOf returns the shell exit code for any error.
|
||||
// - typed errors (*errs.PermissionError, *errs.APIError, *errs.ConfigError,
|
||||
// *errs.AuthenticationError, ...) → routed by Category
|
||||
// - *PartialFailureError / *BareError signals → their own Code field
|
||||
// - untyped → ExitInternal
|
||||
func ExitCodeOf(err error) int {
|
||||
if err == nil {
|
||||
return ExitOK
|
||||
}
|
||||
if _, ok := errs.ProblemOf(err); ok {
|
||||
return ExitCodeForCategory(errs.CategoryOf(err))
|
||||
}
|
||||
var pfErr *PartialFailureError
|
||||
if errors.As(err, &pfErr) {
|
||||
return pfErr.Code
|
||||
}
|
||||
var bare *BareError
|
||||
if errors.As(err, &bare) {
|
||||
return bare.Code
|
||||
}
|
||||
return ExitInternal
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func TestExitCodeForCategory(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cat errs.Category
|
||||
want int
|
||||
}{
|
||||
{"validation", errs.CategoryValidation, 2},
|
||||
{"authentication", errs.CategoryAuthentication, 3},
|
||||
{"authorization", errs.CategoryAuthorization, 3},
|
||||
{"config", errs.CategoryConfig, 3},
|
||||
{"network", errs.CategoryNetwork, 4},
|
||||
{"api", errs.CategoryAPI, 1},
|
||||
{"policy", errs.CategoryPolicy, 6},
|
||||
{"internal", errs.CategoryInternal, 5},
|
||||
{"confirmation", errs.CategoryConfirmation, 10},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := ExitCodeForCategory(tc.cat); got != tc.want {
|
||||
t.Errorf("ExitCodeForCategory(%q) = %d, want %d", tc.cat, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeForCategory_UnknownDefaults(t *testing.T) {
|
||||
if got := ExitCodeForCategory(errs.Category("not_a_real_category")); got != ExitInternal {
|
||||
t.Errorf("ExitCodeForCategory(unknown) = %d, want %d (ExitInternal)", got, ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_Nil(t *testing.T) {
|
||||
if got := ExitCodeOf(nil); got != 0 {
|
||||
t.Errorf("ExitCodeOf(nil) = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_PermissionError(t *testing.T) {
|
||||
err := &errs.PermissionError{Problem: errs.Problem{Category: errs.CategoryAuthorization}}
|
||||
if got := ExitCodeOf(err); got != 3 {
|
||||
t.Errorf("ExitCodeOf(PermissionError) = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_APIError(t *testing.T) {
|
||||
err := &errs.APIError{Problem: errs.Problem{Category: errs.CategoryAPI}}
|
||||
if got := ExitCodeOf(err); got != 1 {
|
||||
t.Errorf("ExitCodeOf(APIError) = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExitCodeOf_UntypedFallsBackToInternal(t *testing.T) {
|
||||
if got := ExitCodeOf(fmt.Errorf("plain")); got != 5 {
|
||||
t.Errorf("ExitCodeOf(plain) = %d, want 5 (untyped → CategoryInternal → ExitInternal)", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const maxFlattenDepth = 3
|
||||
|
||||
type flatEntry struct {
|
||||
Key string
|
||||
Value string
|
||||
}
|
||||
|
||||
// flattenObject flattens a nested object into dot-notation key-value pairs.
|
||||
// Objects nested beyond maxFlattenDepth levels are serialized as JSON strings.
|
||||
// Keys are sorted alphabetically for deterministic column order.
|
||||
func flattenObject(obj map[string]interface{}, prefix string, depth int) []flatEntry {
|
||||
keys := make([]string, 0, len(obj))
|
||||
for k := range obj {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
var entries []flatEntry
|
||||
for _, k := range keys {
|
||||
v := obj[k]
|
||||
key := k
|
||||
if prefix != "" {
|
||||
key = prefix + "." + k
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case map[string]interface{}:
|
||||
if depth+1 >= maxFlattenDepth {
|
||||
entries = append(entries, flatEntry{Key: key, Value: cellStr(val)})
|
||||
} else {
|
||||
entries = append(entries, flattenObject(val, key, depth+1)...)
|
||||
}
|
||||
default:
|
||||
entries = append(entries, flatEntry{Key: key, Value: cellStr(v)})
|
||||
}
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// collectColumns collects column names from all rows (union set),
|
||||
// preserving first-occurrence order.
|
||||
func collectColumns(rows [][]flatEntry) []string {
|
||||
seen := map[string]bool{}
|
||||
var cols []string
|
||||
for _, row := range rows {
|
||||
for _, e := range row {
|
||||
if !seen[e.Key] {
|
||||
seen[e.Key] = true
|
||||
cols = append(cols, e.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return cols
|
||||
}
|
||||
|
||||
// rowMap converts a slice of flatEntry into a map for column lookup.
|
||||
func rowMap(entries []flatEntry) map[string]string {
|
||||
m := make(map[string]string, len(entries))
|
||||
for _, e := range entries {
|
||||
m[e.Key] = e.Value
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// runeWidth returns the display width of a rune.
|
||||
// CJK characters and some symbols are double-width.
|
||||
func runeWidth(r rune) int {
|
||||
if r == utf8.RuneError {
|
||||
return 1
|
||||
}
|
||||
// CJK Unified Ideographs, CJK Compatibility Ideographs, etc.
|
||||
if (r >= 0x1100 && r <= 0x115F) || // Hangul Jamo
|
||||
r == 0x2329 || r == 0x232A ||
|
||||
(r >= 0x2E80 && r <= 0x303E) || // CJK Radicals, Kangxi, CJK Symbols
|
||||
(r >= 0x3040 && r <= 0x33BF) || // Hiragana, Katakana, Bopomofo, etc.
|
||||
(r >= 0x3400 && r <= 0x4DBF) || // CJK Unified Ideographs Extension A
|
||||
(r >= 0x4E00 && r <= 0xA4CF) || // CJK Unified Ideographs, Yi
|
||||
(r >= 0xA960 && r <= 0xA97C) || // Hangul Jamo Extended-A
|
||||
(r >= 0xAC00 && r <= 0xD7A3) || // Hangul Syllables
|
||||
(r >= 0xF900 && r <= 0xFAFF) || // CJK Compatibility Ideographs
|
||||
(r >= 0xFE10 && r <= 0xFE6F) || // CJK Compatibility Forms, Small Forms
|
||||
(r >= 0xFF01 && r <= 0xFF60) || // Fullwidth Forms
|
||||
(r >= 0xFFE0 && r <= 0xFFE6) || // Fullwidth Signs
|
||||
(r >= 0x1F300 && r <= 0x1F9FF) || // Emoji (Miscellaneous Symbols and Pictographs, Emoticons, etc.)
|
||||
(r >= 0x20000 && r <= 0x2FFFF) || // CJK Unified Ideographs Extension B-F
|
||||
(r >= 0x30000 && r <= 0x3FFFF) { // CJK Unified Ideographs Extension G+
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// stringWidth returns the display width of a string.
|
||||
func stringWidth(s string) int {
|
||||
w := 0
|
||||
for _, r := range s {
|
||||
w += runeWidth(r)
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// truncateToWidth truncates a string to fit within maxWidth display columns.
|
||||
// If truncated, appends "…".
|
||||
func truncateToWidth(s string, maxWidth int) string {
|
||||
if maxWidth <= 0 {
|
||||
return ""
|
||||
}
|
||||
w := 0
|
||||
for i, r := range s {
|
||||
rw := runeWidth(r)
|
||||
if w+rw > maxWidth {
|
||||
return s[:i] + "…"
|
||||
}
|
||||
w += rw
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// flattenItem flattens a single item (object or other) into flatEntry pairs.
|
||||
func flattenItem(item interface{}) []flatEntry {
|
||||
if obj, ok := item.(map[string]interface{}); ok {
|
||||
return flattenObject(obj, "", 0)
|
||||
}
|
||||
return []flatEntry{{Key: "value", Value: cellStr(item)}}
|
||||
}
|
||||
|
||||
// prepareRows converts a data value into flattened rows and column names.
|
||||
// Returns rows (as maps), columns, and whether the data was a list.
|
||||
func prepareRows(data interface{}) (rows []map[string]string, cols []string, isList bool) {
|
||||
items := extractArray(data)
|
||||
if items == nil {
|
||||
// Single object
|
||||
if obj, ok := data.(map[string]interface{}); ok {
|
||||
entries := flattenObject(obj, "", 0)
|
||||
rm := rowMap(entries)
|
||||
flatRows := [][]flatEntry{entries}
|
||||
return []map[string]string{rm}, collectColumns(flatRows), false
|
||||
}
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
isList = true
|
||||
var flatRows [][]flatEntry
|
||||
for _, item := range items {
|
||||
entries := flattenItem(item)
|
||||
flatRows = append(flatRows, entries)
|
||||
rows = append(rows, rowMap(entries))
|
||||
}
|
||||
cols = collectColumns(flatRows)
|
||||
return rows, cols, isList
|
||||
}
|
||||
|
||||
// extractArray extracts an array from data, or returns nil.
|
||||
func extractArray(data interface{}) []interface{} {
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
return arr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFlattenObjectSimple(t *testing.T) {
|
||||
obj := map[string]interface{}{
|
||||
"name": "Alice",
|
||||
"age": float64(30),
|
||||
}
|
||||
entries := flattenObject(obj, "", 0)
|
||||
m := rowMap(entries)
|
||||
|
||||
if m["name"] != "Alice" {
|
||||
t.Errorf("name = %q, want %q", m["name"], "Alice")
|
||||
}
|
||||
if m["age"] != "30" {
|
||||
t.Errorf("age = %q, want %q", m["age"], "30")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenObjectNested(t *testing.T) {
|
||||
obj := map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "Alice",
|
||||
"addr": map[string]interface{}{
|
||||
"city": "Beijing",
|
||||
},
|
||||
},
|
||||
}
|
||||
entries := flattenObject(obj, "", 0)
|
||||
m := rowMap(entries)
|
||||
|
||||
if m["user.name"] != "Alice" {
|
||||
t.Errorf("user.name = %q, want %q", m["user.name"], "Alice")
|
||||
}
|
||||
if m["user.addr.city"] != "Beijing" {
|
||||
t.Errorf("user.addr.city = %q, want %q", m["user.addr.city"], "Beijing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenObjectDeepLimit(t *testing.T) {
|
||||
// Create depth=4 nesting — should serialize the innermost object as JSON
|
||||
obj := map[string]interface{}{
|
||||
"a": map[string]interface{}{
|
||||
"b": map[string]interface{}{
|
||||
"c": map[string]interface{}{
|
||||
"d": "deep",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
entries := flattenObject(obj, "", 0)
|
||||
m := rowMap(entries)
|
||||
|
||||
// depth 0 → a (map), depth 1 → b (map), depth 2 → c (map), depth 3 ≥ maxFlattenDepth → serialize
|
||||
if v, ok := m["a.b.c"]; !ok {
|
||||
t.Errorf("expected key a.b.c, got keys: %v", m)
|
||||
} else if v != `{"d":"deep"}` {
|
||||
t.Errorf("a.b.c = %q, want JSON string", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenObjectArrayLeaf(t *testing.T) {
|
||||
obj := map[string]interface{}{
|
||||
"tags": []interface{}{"a", "b"},
|
||||
}
|
||||
entries := flattenObject(obj, "", 0)
|
||||
m := rowMap(entries)
|
||||
|
||||
if m["tags"] != `["a","b"]` {
|
||||
t.Errorf("tags = %q, want %q", m["tags"], `["a","b"]`)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlattenObjectNilValue(t *testing.T) {
|
||||
obj := map[string]interface{}{
|
||||
"empty": nil,
|
||||
}
|
||||
entries := flattenObject(obj, "", 0)
|
||||
m := rowMap(entries)
|
||||
|
||||
if m["empty"] != "" {
|
||||
t.Errorf("empty = %q, want %q", m["empty"], "")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectColumns(t *testing.T) {
|
||||
rows := [][]flatEntry{
|
||||
{{Key: "a", Value: "1"}, {Key: "b", Value: "2"}},
|
||||
{{Key: "b", Value: "3"}, {Key: "c", Value: "4"}},
|
||||
}
|
||||
cols := collectColumns(rows)
|
||||
|
||||
// Should contain a, b, c (union)
|
||||
colSet := map[string]bool{}
|
||||
for _, c := range cols {
|
||||
colSet[c] = true
|
||||
}
|
||||
for _, expected := range []string{"a", "b", "c"} {
|
||||
if !colSet[expected] {
|
||||
t.Errorf("missing column %q in %v", expected, cols)
|
||||
}
|
||||
}
|
||||
if len(cols) != 3 {
|
||||
t.Errorf("got %d columns, want 3", len(cols))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateToWidth(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
maxWidth int
|
||||
want string
|
||||
}{
|
||||
{"hello", 10, "hello"},
|
||||
{"hello", 5, "hello"},
|
||||
{"hello", 4, "hell…"},
|
||||
{"hello", 3, "hel…"},
|
||||
{"hello", 1, "h…"},
|
||||
{"hello", 0, ""},
|
||||
// CJK: each char is width 2
|
||||
{"你好世界", 8, "你好世界"},
|
||||
{"你好世界", 6, "你好世…"},
|
||||
{"你好世界", 4, "你好…"},
|
||||
{"你好世界", 3, "你…"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := truncateToWidth(tt.input, tt.maxWidth)
|
||||
if got != tt.want {
|
||||
t.Errorf("truncateToWidth(%q, %d) = %q, want %q", tt.input, tt.maxWidth, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStringWidth(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want int
|
||||
}{
|
||||
{"hello", 5},
|
||||
{"你好", 4},
|
||||
{"ab你好cd", 8},
|
||||
{"", 0},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got := stringWidth(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("stringWidth(%q) = %d, want %d", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// Known array field names for pagination.
|
||||
var knownArrayFields = []string{
|
||||
"items", "files", "events", "rooms", "records", "nodes",
|
||||
"members", "departments", "calendar_list", "acl_list", "freebusy_list",
|
||||
"users",
|
||||
}
|
||||
|
||||
// FindArrayField finds the primary array field in a response's data object.
|
||||
// It first checks knownArrayFields in priority order, then falls back to
|
||||
// the lexicographically smallest unknown array field for deterministic results.
|
||||
func FindArrayField(data map[string]interface{}) string {
|
||||
for _, name := range knownArrayFields {
|
||||
if arr, ok := data[name]; ok {
|
||||
if _, isArr := arr.([]interface{}); isArr {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: lexicographically first array field (deterministic)
|
||||
var candidates []string
|
||||
for k, v := range data {
|
||||
if _, isArr := v.([]interface{}); isArr {
|
||||
candidates = append(candidates, k)
|
||||
}
|
||||
}
|
||||
if len(candidates) > 0 {
|
||||
sort.Strings(candidates)
|
||||
return candidates[0]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// toGeneric normalises any Go value (structs, typed slices, …) into
|
||||
// plain map[string]interface{} / []interface{} via a JSON round-trip so
|
||||
// that subsequent type assertions in format handlers work uniformly.
|
||||
func toGeneric(v interface{}) interface{} {
|
||||
switch v.(type) {
|
||||
case map[string]interface{}, []interface{}, nil:
|
||||
return v // already generic
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return v
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(b))
|
||||
dec.UseNumber() // preserve int64 precision (avoid float64 truncation)
|
||||
var out interface{}
|
||||
if err := dec.Decode(&out); err != nil {
|
||||
return v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ExtractItems extracts the data array from a response.
|
||||
// It tries two strategies in order:
|
||||
// 1. Lark API envelope: result["data"][arrayField] (e.g. {"code":0,"data":{"items":[…]}})
|
||||
// 2. Direct map: result[arrayField] (e.g. {"members":[…],"total":5})
|
||||
//
|
||||
// If data is already a plain []interface{}, it is returned as-is.
|
||||
func ExtractItems(data interface{}) []interface{} {
|
||||
resultMap, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
return arr
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Strategy 1: Lark API envelope — result["data"][arrayField]
|
||||
if dataObj, ok := resultMap["data"].(map[string]interface{}); ok {
|
||||
if field := FindArrayField(dataObj); field != "" {
|
||||
if items, ok := dataObj[field].([]interface{}); ok {
|
||||
return items
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: direct map — result[arrayField]
|
||||
// Covers shortcut-level data like {"members":[…], "total":5, "has_more":false}
|
||||
if field := FindArrayField(resultMap); field != "" {
|
||||
if items, ok := resultMap[field].([]interface{}); ok {
|
||||
return items
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatValue formats a single response and writes it to w.
|
||||
func FormatValue(w io.Writer, data interface{}, format Format) {
|
||||
data = toGeneric(data)
|
||||
switch format {
|
||||
case FormatNDJSON:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
PrintNdjson(w, items)
|
||||
} else {
|
||||
PrintNdjson(w, data)
|
||||
}
|
||||
|
||||
case FormatTable:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
FormatAsTable(w, items)
|
||||
} else {
|
||||
FormatAsTable(w, data)
|
||||
}
|
||||
|
||||
case FormatCSV:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
FormatAsCSV(w, items)
|
||||
} else {
|
||||
FormatAsCSV(w, data)
|
||||
}
|
||||
|
||||
default: // FormatJSON
|
||||
PrintJson(w, data)
|
||||
}
|
||||
}
|
||||
|
||||
// PaginatedFormatter holds state across paginated calls to ensure
|
||||
// consistent columns (table/csv use the first page's columns for all pages).
|
||||
type PaginatedFormatter struct {
|
||||
W io.Writer
|
||||
Format Format
|
||||
isFirstPage bool
|
||||
cols []string // locked after first page
|
||||
}
|
||||
|
||||
// NewPaginatedFormatter creates a formatter that tracks pagination state.
|
||||
func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
|
||||
return &PaginatedFormatter{W: w, Format: format, isFirstPage: true}
|
||||
}
|
||||
|
||||
// FormatPage formats one page of items.
|
||||
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
|
||||
switch pf.Format {
|
||||
case FormatJSON, FormatNDJSON:
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
PrintNdjson(pf.W, arr)
|
||||
} else {
|
||||
PrintNdjson(pf.W, data)
|
||||
}
|
||||
|
||||
case FormatTable:
|
||||
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
|
||||
widths := computeColumnWidths(rows, cols)
|
||||
if isFirst {
|
||||
writeHeader(w, cols, widths)
|
||||
}
|
||||
for _, row := range rows {
|
||||
writeRow(w, row, cols, widths)
|
||||
}
|
||||
})
|
||||
|
||||
case FormatCSV:
|
||||
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
|
||||
writeCSVRows(w, rows, cols, isFirst)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// formatStructuredPage handles column-locking logic shared by table and csv.
|
||||
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool)) {
|
||||
rows, pageCols, isList := prepareRows(data)
|
||||
if len(rows) == 0 {
|
||||
if pf.isFirstPage && isList {
|
||||
fmt.Fprintln(pf.W, "(empty)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if pf.isFirstPage {
|
||||
// Lock columns from first page
|
||||
pf.cols = pageCols
|
||||
pf.isFirstPage = false
|
||||
emit(pf.W, rows, pf.cols, true)
|
||||
} else {
|
||||
// Reuse first page's columns — missing keys become empty, extra keys ignored
|
||||
emit(pf.W, rows, pf.cols, false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatValue_JSON(t *testing.T) {
|
||||
data := map[string]interface{}{"name": "Alice"}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatValue(&buf, data, FormatJSON)
|
||||
out := buf.String()
|
||||
|
||||
// Should be pretty-printed JSON
|
||||
if !strings.Contains(out, `"name"`) {
|
||||
t.Errorf("JSON output should contain field name, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Alice") {
|
||||
t.Errorf("JSON output should contain value, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_NDJSON(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": float64(1)},
|
||||
map[string]interface{}{"id": float64(2)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatValue(&buf, data, FormatNDJSON)
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("NDJSON should output 2 lines, got %d:\n%s", len(lines), buf.String())
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(line), &obj); err != nil {
|
||||
t.Errorf("each NDJSON line should be valid JSON: %s", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_Table(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatValue(&buf, data, FormatTable)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "name") {
|
||||
t.Errorf("table output should contain 'name' header, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Alice") {
|
||||
t.Errorf("table output should contain 'Alice', got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_CSV(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatValue(&buf, data, FormatCSV)
|
||||
out := buf.String()
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("CSV should have header + 1 row, got %d lines:\n%s", len(lines), out)
|
||||
}
|
||||
if lines[0] != "name" {
|
||||
t.Errorf("CSV header should be 'name', got: %s", lines[0])
|
||||
}
|
||||
if lines[1] != "Alice" {
|
||||
t.Errorf("CSV row should be 'Alice', got: %s", lines[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginatedFormatter_JSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
pf := NewPaginatedFormatter(&buf, FormatJSON)
|
||||
|
||||
pf.FormatPage([]interface{}{
|
||||
map[string]interface{}{"id": float64(1)},
|
||||
map[string]interface{}{"id": float64(2)},
|
||||
})
|
||||
lines := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
if len(lines) != 2 {
|
||||
t.Errorf("paginated JSON should emit 2 lines (NDJSON), got %d:\n%s", len(lines), buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginatedFormatter_NDJSON(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
pf := NewPaginatedFormatter(&buf, FormatNDJSON)
|
||||
|
||||
pf.FormatPage([]interface{}{map[string]interface{}{"id": float64(1)}})
|
||||
out := strings.TrimSpace(buf.String())
|
||||
|
||||
var obj map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(out), &obj); err != nil {
|
||||
t.Errorf("NDJSON paginated output should be valid JSON: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginatedFormatter_Table(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
pf := NewPaginatedFormatter(&buf, FormatTable)
|
||||
|
||||
page1 := []interface{}{map[string]interface{}{"name": "Alice"}}
|
||||
page2 := []interface{}{map[string]interface{}{"name": "Bob"}}
|
||||
|
||||
pf.FormatPage(page1)
|
||||
out1 := buf.String()
|
||||
if !strings.Contains(out1, "─") {
|
||||
t.Error("first table page should contain separator")
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
pf.FormatPage(page2)
|
||||
out2 := buf.String()
|
||||
if strings.Contains(out2, "─") {
|
||||
t.Error("continuation table page should not contain separator")
|
||||
}
|
||||
if !strings.Contains(out2, "Bob") {
|
||||
t.Error("continuation table page should contain data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginatedFormatter_CSV(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
pf := NewPaginatedFormatter(&buf, FormatCSV)
|
||||
|
||||
page1 := []interface{}{map[string]interface{}{"name": "Alice"}}
|
||||
page2 := []interface{}{map[string]interface{}{"name": "Bob"}}
|
||||
|
||||
pf.FormatPage(page1)
|
||||
lines1 := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
if len(lines1) != 2 {
|
||||
t.Errorf("first CSV page should have header + data, got %d lines", len(lines1))
|
||||
}
|
||||
|
||||
buf.Reset()
|
||||
pf.FormatPage(page2)
|
||||
lines2 := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")
|
||||
if len(lines2) != 1 {
|
||||
t.Errorf("continuation CSV page should have only data, got %d lines", len(lines2))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaginatedFormatter_ColumnConsistency(t *testing.T) {
|
||||
// Page 1 has {a, b}, page 2 has {a, b, c} — c should be ignored in CSV
|
||||
var buf bytes.Buffer
|
||||
pf := NewPaginatedFormatter(&buf, FormatCSV)
|
||||
|
||||
pf.FormatPage([]interface{}{map[string]interface{}{"a": "1", "b": "2"}})
|
||||
header := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n")[0]
|
||||
|
||||
buf.Reset()
|
||||
pf.FormatPage([]interface{}{map[string]interface{}{"a": "3", "b": "4", "c": "5"}})
|
||||
dataLine := strings.TrimRight(buf.String(), "\n")
|
||||
|
||||
// Header and data should have same number of columns
|
||||
headerCols := strings.Count(header, ",") + 1
|
||||
dataCols := strings.Count(dataLine, ",") + 1
|
||||
if headerCols != dataCols {
|
||||
t.Errorf("column count mismatch: header has %d, data has %d\nheader: %s\ndata: %s",
|
||||
headerCols, dataCols, header, dataLine)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractItems(t *testing.T) {
|
||||
// Standard Lark response
|
||||
data := map[string]interface{}{
|
||||
"code": float64(0),
|
||||
"msg": "success",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": float64(1)},
|
||||
map[string]interface{}{"id": float64(2)},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "abc",
|
||||
},
|
||||
}
|
||||
|
||||
items := ExtractItems(data)
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 items, got %d", len(items))
|
||||
}
|
||||
|
||||
// Different array field
|
||||
data2 := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"members": []interface{}{
|
||||
map[string]interface{}{"user_id": "u1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
items2 := ExtractItems(data2)
|
||||
if len(items2) != 1 {
|
||||
t.Fatalf("expected 1 member, got %d", len(items2))
|
||||
}
|
||||
|
||||
// Already an array
|
||||
arr := []interface{}{"a", "b"}
|
||||
items3 := ExtractItems(arr)
|
||||
if len(items3) != 2 {
|
||||
t.Fatalf("expected 2 items from raw array, got %d", len(items3))
|
||||
}
|
||||
|
||||
// Non-response
|
||||
items4 := ExtractItems("string")
|
||||
if items4 != nil {
|
||||
t.Fatalf("expected nil for non-response, got %v", items4)
|
||||
}
|
||||
|
||||
// No data field and no array field
|
||||
items5 := ExtractItems(map[string]interface{}{"foo": "bar"})
|
||||
if items5 != nil {
|
||||
t.Fatalf("expected nil for no data/array field, got %v", items5)
|
||||
}
|
||||
|
||||
// Direct map with array field (shortcut data like {"members":[…], "total":5})
|
||||
directMap := map[string]interface{}{
|
||||
"members": []interface{}{map[string]interface{}{"name": "Alice"}},
|
||||
"total": float64(1),
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
}
|
||||
items6 := ExtractItems(directMap)
|
||||
if len(items6) != 1 {
|
||||
t.Fatalf("expected 1 item from direct map, got %d", len(items6))
|
||||
}
|
||||
|
||||
// Direct map — plain array passed directly (e.g. calendar freebusy items)
|
||||
plainArr := []interface{}{
|
||||
map[string]interface{}{"start": "10:00", "end": "11:00"},
|
||||
}
|
||||
items7 := ExtractItems(plainArr)
|
||||
if len(items7) != 1 {
|
||||
t.Fatalf("expected 1 item from plain array, got %d", len(items7))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatValue_LegacyFormats(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// "data" parses to FormatJSON with ok=false
|
||||
dataFmt, dataOK := ParseFormat("data")
|
||||
if dataOK {
|
||||
t.Error("ParseFormat('data') should return ok=false")
|
||||
}
|
||||
var buf2 bytes.Buffer
|
||||
FormatValue(&buf2, data, dataFmt)
|
||||
out2 := buf2.String()
|
||||
if !strings.Contains(out2, "items") {
|
||||
t.Errorf("ParseFormat('data') → JSON should output full response, got:\n%s", out2)
|
||||
}
|
||||
|
||||
// unknown format parses to FormatJSON with ok=false
|
||||
fooFmt, fooOK := ParseFormat("foobar")
|
||||
if fooOK {
|
||||
t.Error("ParseFormat('foobar') should return ok=false")
|
||||
}
|
||||
var buf3 bytes.Buffer
|
||||
FormatValue(&buf3, data, fooFmt)
|
||||
out3 := buf3.String()
|
||||
if !strings.Contains(out3, "items") {
|
||||
t.Errorf("ParseFormat('foobar') → JSON should output full response, got:\n%s", out3)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import "strings"
|
||||
|
||||
// Format represents an output format type.
|
||||
type Format int
|
||||
|
||||
const (
|
||||
FormatJSON Format = iota
|
||||
FormatNDJSON
|
||||
FormatTable
|
||||
FormatCSV
|
||||
)
|
||||
|
||||
// ParseFormat parses a format string into a Format value.
|
||||
// The second return value is false if the format string was not recognized,
|
||||
// in which case FormatJSON is returned as default.
|
||||
func ParseFormat(s string) (Format, bool) {
|
||||
switch strings.ToLower(s) {
|
||||
case "json", "":
|
||||
return FormatJSON, true
|
||||
case "ndjson":
|
||||
return FormatNDJSON, true
|
||||
case "table":
|
||||
return FormatTable, true
|
||||
case "csv":
|
||||
return FormatCSV, true
|
||||
default:
|
||||
return FormatJSON, false
|
||||
}
|
||||
}
|
||||
|
||||
// String returns the string representation of a Format.
|
||||
func (f Format) String() string {
|
||||
switch f {
|
||||
case FormatNDJSON:
|
||||
return "ndjson"
|
||||
case FormatTable:
|
||||
return "table"
|
||||
case FormatCSV:
|
||||
return "csv"
|
||||
default:
|
||||
return "json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
want Format
|
||||
wantOK bool
|
||||
}{
|
||||
{"json", FormatJSON, true},
|
||||
{"JSON", FormatJSON, true},
|
||||
{"Json", FormatJSON, true},
|
||||
{"ndjson", FormatNDJSON, true},
|
||||
{"NDJSON", FormatNDJSON, true},
|
||||
{"Ndjson", FormatNDJSON, true},
|
||||
{"table", FormatTable, true},
|
||||
{"TABLE", FormatTable, true},
|
||||
{"Table", FormatTable, true},
|
||||
{"csv", FormatCSV, true},
|
||||
{"CSV", FormatCSV, true},
|
||||
{"Csv", FormatCSV, true},
|
||||
{"", FormatJSON, true},
|
||||
// Legacy/unknown values fall back to JSON with ok=false
|
||||
{"data", FormatJSON, false},
|
||||
{"raw", FormatJSON, false},
|
||||
{"RAW", FormatJSON, false},
|
||||
{"DATA", FormatJSON, false},
|
||||
{"foobar", FormatJSON, false},
|
||||
{"xml", FormatJSON, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, ok := ParseFormat(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("ParseFormat(%q) format = %v, want %v", tt.input, got, tt.want)
|
||||
}
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ParseFormat(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatString(t *testing.T) {
|
||||
tests := []struct {
|
||||
format Format
|
||||
want string
|
||||
}{
|
||||
{FormatJSON, "json"},
|
||||
{FormatNDJSON, "ndjson"},
|
||||
{FormatTable, "table"},
|
||||
{FormatCSV, "csv"},
|
||||
{Format(99), "json"}, // unknown falls back
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.want, func(t *testing.T) {
|
||||
got := tt.format.String()
|
||||
if got != tt.want {
|
||||
t.Errorf("Format(%d).String() = %q, want %q", tt.format, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
|
||||
"github.com/itchyny/gojq"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// JqFilter applies a jq expression to data and writes the results to w.
|
||||
// Scalar values are printed raw (no quotes for strings), matching jq -r behavior.
|
||||
// Complex values (maps, arrays) are printed as indented JSON with Go's default
|
||||
// HTML escaping (<, >, & → <, >, &).
|
||||
func JqFilter(w io.Writer, data interface{}, expr string) error {
|
||||
return jqFilter(w, data, expr, false)
|
||||
}
|
||||
|
||||
// JqFilterRaw is like JqFilter but disables HTML escaping when re-marshaling
|
||||
// complex jq results. Use it alongside OutRaw when the upstream envelope
|
||||
// carries XML/HTML content that must survive --jq '.data.document' style
|
||||
// projections without getting mangled into < escapes.
|
||||
func JqFilterRaw(w io.Writer, data interface{}, expr string) error {
|
||||
return jqFilter(w, data, expr, true)
|
||||
}
|
||||
|
||||
func jqFilter(w io.Writer, data interface{}, expr string, raw bool) error {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
|
||||
}
|
||||
code, err := gojq.Compile(query)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Normalize data through toGeneric so typed structs become map[string]any.
|
||||
normalized := toGeneric(data)
|
||||
// Convert json.Number values to gojq-compatible types.
|
||||
normalized = convertNumbers(normalized)
|
||||
|
||||
iter := code.Run(normalized)
|
||||
for {
|
||||
v, ok := iter.Next()
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
if err, isErr := v.(error); isErr {
|
||||
return errs.NewAPIError(errs.SubtypeUnknown, "jq error: %s", err).WithCause(err)
|
||||
}
|
||||
if err := writeJqValue(w, v, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJqFlags checks --jq flag compatibility with --output and --format flags,
|
||||
// and validates the jq expression syntax. Returns nil if jqExpr is empty.
|
||||
func ValidateJqFlags(jqExpr, outputFlag, format string) error {
|
||||
if jqExpr == "" {
|
||||
return nil
|
||||
}
|
||||
if outputFlag != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--jq and --output are mutually exclusive")
|
||||
}
|
||||
if format != "" && format != "json" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--jq and --format %s are mutually exclusive", format)
|
||||
}
|
||||
return ValidateJqExpression(jqExpr)
|
||||
}
|
||||
|
||||
// ValidateJqExpression checks whether a jq expression is syntactically valid.
|
||||
func ValidateJqExpression(expr string) error {
|
||||
query, err := gojq.Parse(expr)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
|
||||
}
|
||||
_, err = gojq.Compile(query)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid jq expression: %s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeJqValue writes a single jq result value to w.
|
||||
// Scalars are printed raw; complex values as indented JSON.
|
||||
// When raw is true, HTML escaping is disabled on complex values so that
|
||||
// embedded XML/HTML content is preserved as-is.
|
||||
func writeJqValue(w io.Writer, v interface{}, raw bool) error {
|
||||
switch val := v.(type) {
|
||||
case nil:
|
||||
fmt.Fprintln(w, "null")
|
||||
case bool:
|
||||
fmt.Fprintln(w, val)
|
||||
case int:
|
||||
fmt.Fprintln(w, val)
|
||||
case float64:
|
||||
// Use %g to avoid trailing zeros, matching jq behavior.
|
||||
fmt.Fprintf(w, "%g\n", val)
|
||||
case *big.Int:
|
||||
fmt.Fprintln(w, val.String())
|
||||
case string:
|
||||
// Raw output for strings (no quotes), matching jq -r.
|
||||
fmt.Fprintln(w, val)
|
||||
default:
|
||||
// Complex value (map, array): indented JSON.
|
||||
if raw {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(v); err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to marshal jq result: %s", err).WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
b, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeSDKError, "failed to marshal jq result: %s", err).WithCause(err)
|
||||
}
|
||||
fmt.Fprintln(w, string(b))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// convertNumbers recursively converts json.Number values to int or float64
|
||||
// so that gojq can process them correctly.
|
||||
func convertNumbers(v interface{}) interface{} {
|
||||
switch val := v.(type) {
|
||||
case json.Number:
|
||||
if i, err := val.Int64(); err == nil {
|
||||
return int(i)
|
||||
}
|
||||
if f, err := val.Float64(); err == nil {
|
||||
return f
|
||||
}
|
||||
// Fallback: return as string (shouldn't happen for valid JSON numbers).
|
||||
return val.String()
|
||||
case map[string]interface{}:
|
||||
for k, elem := range val {
|
||||
val[k] = convertNumbers(elem)
|
||||
}
|
||||
return val
|
||||
case []interface{}:
|
||||
for i, elem := range val {
|
||||
val[i] = convertNumbers(elem)
|
||||
}
|
||||
return val
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJqFilterRaw_PreservesXMLInComplexValue(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"document": map[string]interface{}{
|
||||
"title": "<title>hello & welcome</title>",
|
||||
"content": "<p>a < b & c > d</p>",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var raw bytes.Buffer
|
||||
if err := JqFilterRaw(&raw, data, ".data.document"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// Raw path must keep <, >, & as literal characters, not Go json-encoder's
|
||||
// default < / > / & unicode escapes.
|
||||
for _, unicodeEsc := range []string{"\\u003c", "\\u003e", "\\u0026"} {
|
||||
if strings.Contains(raw.String(), unicodeEsc) {
|
||||
t.Errorf("JqFilterRaw unexpectedly HTML-escaped %s: %s", unicodeEsc, raw.String())
|
||||
}
|
||||
}
|
||||
if !strings.Contains(raw.String(), "<title>") {
|
||||
t.Errorf("JqFilterRaw dropped raw <title>: %s", raw.String())
|
||||
}
|
||||
|
||||
var escaped bytes.Buffer
|
||||
if err := JqFilter(&escaped, data, ".data.document"); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// JqFilter keeps Go's default HTML escaping for back-compat.
|
||||
if !strings.Contains(escaped.String(), "\\u003c") {
|
||||
t.Errorf("JqFilter should HTML-escape < for back-compat: %s", escaped.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestJqFilterRaw_ScalarMatchesJqFilter(t *testing.T) {
|
||||
data := map[string]interface{}{"content": "<title>hello</title>"}
|
||||
|
||||
var raw, plain bytes.Buffer
|
||||
if err := JqFilterRaw(&raw, data, ".content"); err != nil {
|
||||
t.Fatalf("raw: %v", err)
|
||||
}
|
||||
if err := JqFilter(&plain, data, ".content"); err != nil {
|
||||
t.Fatalf("plain: %v", err)
|
||||
}
|
||||
// Scalar string path is raw in both (matches jq -r), so output is identical.
|
||||
if raw.String() != plain.String() {
|
||||
t.Errorf("scalar output diverged: raw=%q plain=%q", raw.String(), plain.String())
|
||||
}
|
||||
if !strings.Contains(raw.String(), "<title>") {
|
||||
t.Errorf("scalar output dropped <title>: %q", raw.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJqFilter(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"ok": true,
|
||||
"identity": "user",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"name": "Alice", "age": 30},
|
||||
map[string]interface{}{"name": "Bob", "age": 25},
|
||||
map[string]interface{}{"name": "Charlie", "age": 35},
|
||||
},
|
||||
"total": 3,
|
||||
},
|
||||
"meta": map[string]interface{}{
|
||||
"count": 3,
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "identity expression",
|
||||
expr: ".",
|
||||
want: `"ok"`,
|
||||
},
|
||||
{
|
||||
name: "field access .ok",
|
||||
expr: ".ok",
|
||||
want: "true\n",
|
||||
},
|
||||
{
|
||||
name: "string field raw output",
|
||||
expr: ".identity",
|
||||
want: "user\n",
|
||||
},
|
||||
{
|
||||
name: "nested field access",
|
||||
expr: ".data.total",
|
||||
want: "3\n",
|
||||
},
|
||||
{
|
||||
name: "meta count",
|
||||
expr: ".meta.count",
|
||||
want: "3\n",
|
||||
},
|
||||
{
|
||||
name: "array iteration",
|
||||
expr: ".data.items[].name",
|
||||
want: "Alice\nBob\nCharlie\n",
|
||||
},
|
||||
{
|
||||
name: "pipe and select",
|
||||
expr: `.data.items[] | select(.age > 28) | .name`,
|
||||
want: "Alice\nCharlie\n",
|
||||
},
|
||||
{
|
||||
name: "length builtin",
|
||||
expr: ".data.items | length",
|
||||
want: "3\n",
|
||||
},
|
||||
{
|
||||
name: "keys builtin",
|
||||
expr: ".data | keys",
|
||||
want: "[\n \"items\",\n \"total\"\n]\n",
|
||||
},
|
||||
{
|
||||
name: "null for missing field",
|
||||
expr: ".nonexistent",
|
||||
want: "null\n",
|
||||
},
|
||||
{
|
||||
name: "complex value output",
|
||||
expr: ".data.items[0]",
|
||||
want: "{\n \"age\": 30,\n \"name\": \"Alice\"\n}\n",
|
||||
},
|
||||
{
|
||||
name: "invalid expression",
|
||||
expr: "invalid[",
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "multiple outputs",
|
||||
expr: ".ok, .identity",
|
||||
want: "true\nuser\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
err := JqFilter(&buf, data, tt.expr)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if tt.name == "identity expression" {
|
||||
// For identity, just verify it contains the key fields
|
||||
if !strings.Contains(buf.String(), `"ok"`) {
|
||||
t.Errorf("identity output missing 'ok' key")
|
||||
}
|
||||
return
|
||||
}
|
||||
if buf.String() != tt.want {
|
||||
t.Errorf("got %q, want %q", buf.String(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJqFilter_WithStruct(t *testing.T) {
|
||||
// Test that toGeneric normalizes structs properly
|
||||
type inner struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
data := struct {
|
||||
OK bool `json:"ok"`
|
||||
Item *inner `json:"item"`
|
||||
}{
|
||||
OK: true,
|
||||
Item: &inner{Name: "test"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := JqFilter(&buf, data, ".item.name")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := strings.TrimSpace(buf.String()); got != "test" {
|
||||
t.Errorf("got %q, want %q", got, "test")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJqFlags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jqExpr string
|
||||
outputFlag string
|
||||
format string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "empty jq is noop", jqExpr: "", outputFlag: "file.json", format: "csv", wantErr: ""},
|
||||
{name: "jq only", jqExpr: ".data", outputFlag: "", format: "", wantErr: ""},
|
||||
{name: "jq with json format", jqExpr: ".data", outputFlag: "", format: "json", wantErr: ""},
|
||||
{name: "jq and output conflict", jqExpr: ".data", outputFlag: "out.json", format: "", wantErr: "--jq and --output are mutually exclusive"},
|
||||
{name: "jq and csv conflict", jqExpr: ".data", outputFlag: "", format: "csv", wantErr: "--jq and --format csv are mutually exclusive"},
|
||||
{name: "jq and ndjson conflict", jqExpr: ".data", outputFlag: "", format: "ndjson", wantErr: "--jq and --format ndjson are mutually exclusive"},
|
||||
{name: "invalid expression", jqExpr: "invalid[", outputFlag: "", format: "", wantErr: "invalid jq expression"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateJqFlags(tt.jqExpr, tt.outputFlag, tt.format)
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Errorf("error %q does not contain %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateJqExpression(t *testing.T) {
|
||||
tests := []struct {
|
||||
expr string
|
||||
wantErr bool
|
||||
}{
|
||||
{".", false},
|
||||
{".data", false},
|
||||
{".data.items[].name", false},
|
||||
{`.data.items[] | select(.name == "Alice")`, false},
|
||||
{"length", false},
|
||||
{"keys", false},
|
||||
{"invalid[", true},
|
||||
{".foo | invalid_func", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.expr, func(t *testing.T) {
|
||||
err := ValidateJqExpression(tt.expr)
|
||||
if tt.wantErr && err == nil {
|
||||
t.Error("expected error, got nil")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
// Lark API generic error code constants.
|
||||
// ref: https://open.feishu.cn/document/server-docs/api-call-guide/generic-error-code
|
||||
//
|
||||
// Kept as exported identifiers because external shortcut packages reference
|
||||
// them by name (e.g. LarkErrOwnershipMismatch). The canonical Category /
|
||||
// Subtype / Retryable metadata for each code lives in internal/errclass and
|
||||
// must remain the single source of truth.
|
||||
const (
|
||||
// Auth: token missing / invalid / expired.
|
||||
LarkErrTokenMissing = 99991661 // Authorization header missing or empty
|
||||
LarkErrTokenBadFmt = 99991671 // token format error (must start with "t-" or "u-")
|
||||
LarkErrTokenInvalid = 99991668 // user_access_token invalid or expired
|
||||
LarkErrATInvalid = 99991663 // access_token invalid (generic)
|
||||
LarkErrTokenExpired = 99991677 // user_access_token expired, refresh to obtain a new one
|
||||
|
||||
// Permission: scope not granted.
|
||||
LarkErrAppScopeNotEnabled = 99991672 // app has not applied for the required API scope
|
||||
LarkErrTokenNoPermission = 99991676 // token lacks the required scope
|
||||
LarkErrUserScopeInsufficient = 99991679 // user has not granted the required scope
|
||||
LarkErrUserNotAuthorized = 230027 // user not authorized
|
||||
|
||||
// App credential / status.
|
||||
LarkErrAppCredInvalid = 99991543 // app_id or app_secret is incorrect (Open API)
|
||||
LarkErrAppNotInUse = 99991662 // app is disabled in this tenant
|
||||
LarkErrAppUnauthorized = 99991673 // app status unavailable; check installation
|
||||
|
||||
// "Wrong app credentials" code from the LEGACY TAT endpoint
|
||||
// (/open-apis/auth/v3/tenant_access_token/internal returns 10014, "app secret
|
||||
// invalid", instead of 99991543). Since the OAuth v3 migration the CLI mints
|
||||
// TAT via accounts/oauth/v3/token and reports this as the OAuth invalid_client
|
||||
// error, so it no longer emits 10014 itself; the constant + codemeta mapping
|
||||
// are retained as a defensive fallback should 10014 still arrive.
|
||||
LarkErrTATInvalidSecret = 10014
|
||||
|
||||
// Rate limit.
|
||||
LarkErrRateLimit = 99991400 // request frequency limit exceeded
|
||||
|
||||
// Refresh token errors (authn service).
|
||||
LarkErrRefreshInvalid = 20026 // refresh_token invalid or v1 format
|
||||
LarkErrRefreshExpired = 20037 // refresh_token expired
|
||||
LarkErrRefreshRevoked = 20064 // refresh_token revoked
|
||||
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
|
||||
|
||||
// Drive shortcut / cross-space constraints.
|
||||
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
|
||||
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
|
||||
LarkErrDriveCrossBrand = 1064511 // cross brand not support
|
||||
|
||||
// Wiki write-path lock contention (e.g. concurrent wiki +node-create under the
|
||||
// same parent). Server-side write lock; transient, safe to retry with backoff.
|
||||
LarkErrWikiLockContention = 131009
|
||||
|
||||
// Sheets float image: width/height/offset out of range or invalid.
|
||||
LarkErrSheetsFloatImageInvalidDims = 1310246
|
||||
|
||||
// Drive permission apply: per-user-per-document submission limit (5/day) reached.
|
||||
LarkErrDrivePermApplyRateLimit = 1063006
|
||||
// Drive permission apply: request is not applicable for this document
|
||||
// (e.g. the document is configured to disallow access requests, or the
|
||||
// caller already holds the requested permission, or the target type does
|
||||
// not accept apply operations).
|
||||
LarkErrDrivePermApplyNotApplicable = 1063007
|
||||
|
||||
// IM resource ownership mismatch.
|
||||
LarkErrOwnershipMismatch = 231205
|
||||
|
||||
// Mail send: account / mailbox-level failures returned by
|
||||
// POST /open-apis/mail/v1/user_mailboxes/:user_mailbox_id/drafts/:draft_id/send.
|
||||
// Mail v1 uses service-scoped 123xxxx codes; keep the full upstream code
|
||||
// because the typed envelope preserves Problem.Code exactly as returned by
|
||||
// the server.
|
||||
// These codes indicate the entire batch will keep failing identically and
|
||||
// are consumed by shortcuts/mail.isFatalSendErr to abort early.
|
||||
LarkErrMailboxNotFound = 1234013 // mailbox not found or not active
|
||||
LarkErrMailSendQuotaUser = 1236007 // user daily send count exceeded
|
||||
LarkErrMailSendQuotaUserExt = 1236008 // user daily external recipient count exceeded
|
||||
LarkErrMailSendQuotaTenantExt = 1236009 // tenant daily external recipient count exceeded
|
||||
LarkErrMailQuota = 1236010 // mail quota limit
|
||||
LarkErrTenantStorageLimit = 1236013 // tenant storage limit exceeded
|
||||
)
|
||||
@@ -0,0 +1,34 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMailSendErrorConstantsUseServiceScopedCodes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
got int
|
||||
want int
|
||||
}{
|
||||
{name: "mailbox not found", got: LarkErrMailboxNotFound, want: 1234013},
|
||||
{name: "user daily send quota", got: LarkErrMailSendQuotaUser, want: 1236007},
|
||||
{name: "user external recipient quota", got: LarkErrMailSendQuotaUserExt, want: 1236008},
|
||||
{name: "tenant external recipient quota", got: LarkErrMailSendQuotaTenantExt, want: 1236009},
|
||||
{name: "mail quota", got: LarkErrMailQuota, want: 1236010},
|
||||
{name: "tenant storage limit", got: LarkErrTenantStorageLimit, want: 1236013},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if tt.got != tt.want {
|
||||
t.Fatalf("code=%d, want %d", tt.got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
// PrintJson prints data as formatted JSON to w.
|
||||
func PrintJson(w io.Writer, data interface{}) {
|
||||
injectNotice(data)
|
||||
b, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, string(b))
|
||||
}
|
||||
|
||||
// injectNotice adds a "_notice" field into CLI envelope maps.
|
||||
// Only modifies map[string]interface{} values that have an "ok" key
|
||||
// (e.g. doctor, auth, config commands that build map envelopes directly).
|
||||
//
|
||||
// Struct-based envelopes (Envelope, the typed error envelope) are NOT handled
|
||||
// here — callers must set the Notice field explicitly via GetNotice().
|
||||
// See: shortcuts/common/runner.go Out(), output/errors.go WriteTypedErrorEnvelope().
|
||||
func injectNotice(data interface{}) {
|
||||
if PendingNotice == nil {
|
||||
return
|
||||
}
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, isEnvelope := m["ok"]; !isEnvelope {
|
||||
return
|
||||
}
|
||||
notice := PendingNotice()
|
||||
if notice == nil {
|
||||
return
|
||||
}
|
||||
m["_notice"] = notice
|
||||
}
|
||||
|
||||
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
|
||||
func PrintNdjson(w io.Writer, data interface{}) {
|
||||
emit := func(item interface{}) {
|
||||
b, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ndjson marshal error: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintln(w, string(b))
|
||||
}
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
emit(item)
|
||||
}
|
||||
} else {
|
||||
emit(data)
|
||||
}
|
||||
}
|
||||
|
||||
func cellStr(val interface{}) string {
|
||||
if val == nil {
|
||||
return ""
|
||||
}
|
||||
var s string
|
||||
switch v := val.(type) {
|
||||
case string:
|
||||
s = v
|
||||
case json.Number:
|
||||
s = v.String()
|
||||
case float64:
|
||||
if v == float64(int(v)) {
|
||||
s = fmt.Sprintf("%d", int(v))
|
||||
} else {
|
||||
s = fmt.Sprintf("%g", v)
|
||||
}
|
||||
case bool:
|
||||
s = fmt.Sprintf("%v", v)
|
||||
default:
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
s = string(b)
|
||||
}
|
||||
// Sanitize for terminal display: strip ANSI escapes, control chars, dangerous Unicode.
|
||||
return validate.SanitizeForTerminal(s)
|
||||
}
|
||||
|
||||
// PrintTable prints rows as a table to w.
|
||||
// Delegates to FormatAsTable for flattening, column union, and width handling.
|
||||
func PrintTable(w io.Writer, rows []map[string]interface{}) {
|
||||
if len(rows) == 0 {
|
||||
fmt.Fprintln(w, "(no data)")
|
||||
return
|
||||
}
|
||||
items := make([]interface{}, len(rows))
|
||||
for i, r := range rows {
|
||||
items[i] = r
|
||||
}
|
||||
FormatAsTable(w, items)
|
||||
}
|
||||
|
||||
// PrintSuccess prints a success message to w.
|
||||
func PrintSuccess(w io.Writer, msg string) {
|
||||
fmt.Fprintf(w, "OK: %s\n", msg)
|
||||
}
|
||||
|
||||
// PrintError prints an error message to w.
|
||||
func PrintError(w io.Writer, msg string) {
|
||||
fmt.Fprintf(w, "ERROR: %s\n", msg)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPrintJson_InjectNotice_Map(t *testing.T) {
|
||||
origNotice := PendingNotice
|
||||
PendingNotice = func() map[string]interface{} {
|
||||
return map[string]interface{}{"update": "available"}
|
||||
}
|
||||
defer func() { PendingNotice = origNotice }()
|
||||
|
||||
data := map[string]interface{}{"ok": true, "data": "test"}
|
||||
var buf bytes.Buffer
|
||||
PrintJson(&buf, data)
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("failed to parse: %v", err)
|
||||
}
|
||||
notice, ok := got["_notice"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected _notice in map-based envelope")
|
||||
}
|
||||
if notice["update"] != "available" {
|
||||
t.Errorf("expected update=available, got %v", notice["update"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJson_InjectNotice_SkipsNonEnvelope(t *testing.T) {
|
||||
origNotice := PendingNotice
|
||||
PendingNotice = func() map[string]interface{} {
|
||||
return map[string]interface{}{"update": "available"}
|
||||
}
|
||||
defer func() { PendingNotice = origNotice }()
|
||||
|
||||
// Map without "ok" key should not get _notice
|
||||
data := map[string]interface{}{"name": "test"}
|
||||
var buf bytes.Buffer
|
||||
PrintJson(&buf, data)
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("failed to parse: %v", err)
|
||||
}
|
||||
if _, ok := got["_notice"]; ok {
|
||||
t.Error("expected no _notice for non-envelope map")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJson_Struct_PreservesNotice(t *testing.T) {
|
||||
origNotice := PendingNotice
|
||||
PendingNotice = nil // no global notice
|
||||
defer func() { PendingNotice = origNotice }()
|
||||
|
||||
// Struct with Notice already set should preserve it
|
||||
env := &Envelope{
|
||||
OK: true,
|
||||
Identity: "user",
|
||||
Data: "hello",
|
||||
Notice: map[string]interface{}{"update": "set-by-caller"},
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
PrintJson(&buf, env)
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("failed to parse: %v", err)
|
||||
}
|
||||
notice, ok := got["_notice"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatal("expected _notice from struct field")
|
||||
}
|
||||
if notice["update"] != "set-by-caller" {
|
||||
t.Errorf("expected update=set-by-caller, got %v", notice["update"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrintJson_NoNotice(t *testing.T) {
|
||||
origNotice := PendingNotice
|
||||
PendingNotice = nil
|
||||
defer func() { PendingNotice = origNotice }()
|
||||
|
||||
data := map[string]interface{}{"ok": true, "data": "test"}
|
||||
var buf bytes.Buffer
|
||||
PrintJson(&buf, data)
|
||||
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(buf.Bytes(), &got); err != nil {
|
||||
t.Fatalf("failed to parse: %v", err)
|
||||
}
|
||||
if _, ok := got["_notice"]; ok {
|
||||
t.Error("expected no _notice when PendingNotice is nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// spinnerFrames are braille spinner glyphs cycled to animate progress.
|
||||
var spinnerFrames = []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
|
||||
const (
|
||||
spinnerInterval = 80 * time.Millisecond
|
||||
spinnerHideCursor = "\x1b[?25l"
|
||||
spinnerShowCursor = "\x1b[?25h"
|
||||
spinnerClearLine = "\r\x1b[K" // CR + clear-to-end-of-line
|
||||
)
|
||||
|
||||
// StartSpinner renders a braille spinner with an elapsed-seconds counter to w
|
||||
// until the returned stop() is called, e.g.:
|
||||
//
|
||||
// ⠹ Publishing dev → main... 3s
|
||||
//
|
||||
// It is meant for slow operations (long polls, first-time provisioning) so the
|
||||
// user sees the CLI is alive. Always write to STDERR (w = IO().ErrOut) so the
|
||||
// animation never pollutes stdout — the JSON/pretty result stays clean.
|
||||
//
|
||||
// When enabled is false (stderr is not a TTY: pipes, CI, captured output) it is
|
||||
// a no-op returning a no-op stop, so non-interactive runs emit nothing. Gate on
|
||||
// the stderr-TTY check (IOStreams.StderrIsTerminal), not the output format: the
|
||||
// spinner is stderr-only and self-clears, so it is shown in JSON mode too.
|
||||
//
|
||||
// stop() clears the spinner line, restores the cursor, and blocks until the
|
||||
// render goroutine has finished — so callers can safely write the result to
|
||||
// stdout/stderr immediately after. Call stop() BEFORE printing the result, and
|
||||
// it is safe to call more than once (e.g. an explicit call plus a defer).
|
||||
func StartSpinner(w io.Writer, enabled bool, label string) func() {
|
||||
if !enabled || w == nil {
|
||||
return func() {}
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
finished := make(chan struct{})
|
||||
start := time.Now()
|
||||
|
||||
go func() {
|
||||
defer close(finished)
|
||||
frame := 0
|
||||
fmt.Fprint(w, spinnerHideCursor)
|
||||
render := func() {
|
||||
elapsed := int(time.Since(start).Seconds())
|
||||
fmt.Fprintf(w, "%s%s %s... %ds", spinnerClearLine, spinnerFrames[frame], label, elapsed)
|
||||
frame = (frame + 1) % len(spinnerFrames)
|
||||
}
|
||||
render()
|
||||
ticker := time.NewTicker(spinnerInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
fmt.Fprint(w, spinnerClearLine+spinnerShowCursor)
|
||||
return
|
||||
case <-ticker.C:
|
||||
render()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
close(done)
|
||||
<-finished // wait for the line to be cleared before returning
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStartSpinner_DisabledIsNoop asserts that a disabled spinner writes nothing and its stop func is idempotent.
|
||||
func TestStartSpinner_DisabledIsNoop(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
stop := StartSpinner(&buf, false, "working")
|
||||
stop()
|
||||
stop() // idempotent
|
||||
if buf.Len() != 0 {
|
||||
t.Fatalf("disabled spinner wrote %q, want nothing", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestStartSpinner_NilWriterIsNoop asserts that a nil writer is a no-op and stopping does not panic.
|
||||
func TestStartSpinner_NilWriterIsNoop(t *testing.T) {
|
||||
stop := StartSpinner(nil, true, "working")
|
||||
stop() // must not panic
|
||||
}
|
||||
|
||||
// TestStartSpinner_EnabledAnimatesAndCleansUp asserts that an enabled spinner renders a frame and label, then clears the line and restores the cursor on stop.
|
||||
func TestStartSpinner_EnabledAnimatesAndCleansUp(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
stop := StartSpinner(&buf, true, "Publishing")
|
||||
// The goroutine renders the first frame synchronously before selecting on
|
||||
// the stop channel, so even an immediate stop() yields one full cycle.
|
||||
stop()
|
||||
stop() // idempotent, must not panic or double-write after finished
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, spinnerHideCursor) {
|
||||
t.Errorf("missing hide-cursor escape:\n%q", out)
|
||||
}
|
||||
if !strings.Contains(out, spinnerFrames[0]) {
|
||||
t.Errorf("missing first spinner frame %q:\n%q", spinnerFrames[0], out)
|
||||
}
|
||||
if !strings.Contains(out, "Publishing...") {
|
||||
t.Errorf("missing label:\n%q", out)
|
||||
}
|
||||
if !strings.Contains(out, spinnerClearLine) {
|
||||
t.Errorf("missing clear-line escape:\n%q", out)
|
||||
}
|
||||
if !strings.HasSuffix(out, spinnerShowCursor) {
|
||||
t.Errorf("must end by restoring the cursor:\n%q", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const maxColWidth = 100
|
||||
|
||||
// FormatAsTable formats data as a table and writes it to w.
|
||||
// - []interface{} (array of objects) → header + separator + rows
|
||||
// - map[string]interface{} (single object) → key-value two-column table
|
||||
// - empty array → "(empty)"
|
||||
func FormatAsTable(w io.Writer, data interface{}) {
|
||||
FormatAsTablePaginated(w, data, true)
|
||||
}
|
||||
|
||||
// FormatAsTablePaginated formats data as a table with pagination awareness.
|
||||
// When isFirstPage is true, outputs the header; otherwise only data rows.
|
||||
func FormatAsTablePaginated(w io.Writer, data interface{}, isFirstPage bool) {
|
||||
rows, cols, isList := prepareRows(data)
|
||||
if cols == nil {
|
||||
if isList {
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
} else {
|
||||
// Not a list and not an object — print as JSON fallback
|
||||
PrintJson(w, data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
if isFirstPage {
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if !isList {
|
||||
// Single object: key-value two-column format
|
||||
formatKeyValueTable(w, rows[0], cols)
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate column widths (clamped to maxColWidth)
|
||||
widths := computeColumnWidths(rows, cols)
|
||||
|
||||
if isFirstPage {
|
||||
writeHeader(w, cols, widths)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
writeRow(w, row, cols, widths)
|
||||
}
|
||||
}
|
||||
|
||||
// formatKeyValueTable renders a single object as a two-column key-value table.
|
||||
func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) {
|
||||
maxKeyWidth := 0
|
||||
for _, col := range cols {
|
||||
kw := stringWidth(col)
|
||||
if kw > maxKeyWidth {
|
||||
maxKeyWidth = kw
|
||||
}
|
||||
}
|
||||
|
||||
for _, col := range cols {
|
||||
val := row[col]
|
||||
val = truncateToWidth(val, maxColWidth)
|
||||
fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val)
|
||||
}
|
||||
}
|
||||
|
||||
// computeColumnWidths returns display widths for each column, clamped to maxColWidth.
|
||||
func computeColumnWidths(rows []map[string]string, cols []string) []int {
|
||||
widths := make([]int, len(cols))
|
||||
for i, col := range cols {
|
||||
widths[i] = stringWidth(col)
|
||||
}
|
||||
for _, row := range rows {
|
||||
for i, col := range cols {
|
||||
cw := stringWidth(row[col])
|
||||
if cw > widths[i] {
|
||||
widths[i] = cw
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clamp to max
|
||||
for i := range widths {
|
||||
if widths[i] > maxColWidth {
|
||||
widths[i] = maxColWidth
|
||||
}
|
||||
}
|
||||
return widths
|
||||
}
|
||||
|
||||
// writeHeader writes the header row and separator line.
|
||||
func writeHeader(w io.Writer, cols []string, widths []int) {
|
||||
var header []string
|
||||
var sep []string
|
||||
for i, col := range cols {
|
||||
header = append(header, padToWidth(col, widths[i]))
|
||||
sep = append(sep, strings.Repeat("─", widths[i]))
|
||||
}
|
||||
fmt.Fprintln(w, strings.Join(header, " "))
|
||||
fmt.Fprintln(w, strings.Join(sep, " "))
|
||||
}
|
||||
|
||||
// writeRow writes a single data row.
|
||||
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) {
|
||||
var cells []string
|
||||
for i, col := range cols {
|
||||
val := truncateToWidth(row[col], widths[i])
|
||||
cells = append(cells, padToWidth(val, widths[i]))
|
||||
}
|
||||
fmt.Fprintln(w, strings.Join(cells, " "))
|
||||
}
|
||||
|
||||
// padToWidth pads a string with spaces to reach the target display width.
|
||||
func padToWidth(s string, targetWidth int) string {
|
||||
sw := stringWidth(s)
|
||||
if sw >= targetWidth {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", targetWidth-sw)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFormatAsTable_ObjectArray(t *testing.T) {
|
||||
data := []interface{}{
|
||||
map[string]interface{}{"name": "Alice", "age": float64(30)},
|
||||
map[string]interface{}{"name": "Bob", "age": float64(25)},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsTable(&buf, data)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "name") {
|
||||
t.Errorf("output should contain 'name' header, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "age") {
|
||||
t.Errorf("output should contain 'age' header, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Alice") {
|
||||
t.Errorf("output should contain 'Alice', got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Bob") {
|
||||
t.Errorf("output should contain 'Bob', got:\n%s", out)
|
||||
}
|
||||
// Should contain separator with ─
|
||||
if !strings.Contains(out, "─") {
|
||||
t.Errorf("output should contain ─ separator, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsTable_SingleObject(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"name": "Alice",
|
||||
"age": float64(30),
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsTable(&buf, data)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "name") {
|
||||
t.Errorf("output should contain 'name', got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Alice") {
|
||||
t.Errorf("output should contain 'Alice', got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsTable_EmptyArray(t *testing.T) {
|
||||
data := []interface{}{}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsTable(&buf, data)
|
||||
out := strings.TrimSpace(buf.String())
|
||||
|
||||
if out != "(empty)" {
|
||||
t.Errorf("empty array should output '(empty)', got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsTable_NestedFlattening(t *testing.T) {
|
||||
data := []interface{}{
|
||||
map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"name": "Alice",
|
||||
},
|
||||
"id": float64(1),
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsTable(&buf, data)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "user.name") {
|
||||
t.Errorf("output should contain flattened 'user.name' column, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Alice") {
|
||||
t.Errorf("output should contain 'Alice', got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsTable_ColumnUnionFromAllRows(t *testing.T) {
|
||||
data := []interface{}{
|
||||
map[string]interface{}{"a": "1"},
|
||||
map[string]interface{}{"a": "2", "b": "3"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsTable(&buf, data)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "b") {
|
||||
t.Errorf("output should contain column 'b' from second row, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsTablePaginated_FirstPage(t *testing.T) {
|
||||
data := []interface{}{
|
||||
map[string]interface{}{"name": "Alice"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsTablePaginated(&buf, data, true)
|
||||
out := buf.String()
|
||||
|
||||
// First page should have header
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
if len(lines) < 3 {
|
||||
t.Errorf("first page should have header + separator + data, got %d lines:\n%s", len(lines), out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsTablePaginated_ContinuationPage(t *testing.T) {
|
||||
data := []interface{}{
|
||||
map[string]interface{}{"name": "Bob"},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsTablePaginated(&buf, data, false)
|
||||
out := buf.String()
|
||||
|
||||
// Continuation page should not have header/separator
|
||||
if strings.Contains(out, "─") {
|
||||
t.Errorf("continuation page should not contain separator, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "Bob") {
|
||||
t.Errorf("continuation page should contain data, got:\n%s", out)
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
|
||||
if len(lines) != 1 {
|
||||
t.Errorf("continuation page should have 1 data line, got %d lines:\n%s", len(lines), out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatAsTable_ColumnWidthClamp(t *testing.T) {
|
||||
// Create a value longer than maxColWidth
|
||||
longVal := strings.Repeat("x", 101)
|
||||
data := []interface{}{
|
||||
map[string]interface{}{"col": longVal},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
FormatAsTable(&buf, data)
|
||||
out := buf.String()
|
||||
|
||||
if strings.Contains(out, longVal) {
|
||||
t.Errorf("output should not contain the full long value (should be truncated)")
|
||||
}
|
||||
if !strings.Contains(out, "…") {
|
||||
t.Errorf("output should contain truncation marker …, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user