mirror of
https://github.com/larksuite/cli.git
synced 2026-09-14 18:42:53 +08:00
4c1a92caa6
* refactor: add output emitter contract and differential harness Introduce a leaf Emitter in internal/output that composes the existing output primitives (content-safety scan, envelope, jq, format rendering, notice) behind a single command-scoped port. The emitter is unwired: no production caller is migrated, so CLI output stays byte-for-byte unchanged. A differential test harness drives the real legacy entry points (RuntimeContext.Out/OutRaw/OutFormat/..., WriteSuccessEnvelope and the pagination formatter) and asserts byte-identical stdout/stderr plus typed errors, locking behavior before later slices migrate callers. * refactor: tighten emitter API and cover pagination with real tests - split Emitter.Success/PartialFailure and drop EmitOptions.OK so a missing ok flag can no longer silently emit ok:false - give StreamPage its own StreamOptions (format + pretty) instead of reusing EmitOptions, making "jq needs aggregation" a compile-time fact - pin the Emitter jq-error contract (returns error, writes no stderr); the caller adapter re-emits the legacy stderr line on migration - add in-package tests driving the real apiPaginate/servicePaginate over a mock transport: multi-page aggregation, empty-result fallback, MarkRaw handling, and the business-error raw-response red line * test: use standard TestFactory harness for pagination tests Replace the hand-rolled RoundTripper + APIClient construction in the apiPaginate/servicePaginate tests with cmdutil.TestFactory and its httpmock.Registry, and isolate LARKSUITE_CLI_CONFIG_DIR to t.TempDir(), matching the repo's standard HTTP-mocked test convention. Assertions and coverage (multi-page aggregation, empty-result fallback, MarkRaw, and the business-error raw-response red line) are unchanged. * refactor: route success output through the single Emitter port Migrate the success-output surfaces onto internal/output's Emitter, byte-for-byte identical (proven by frozen golden diffs and the real paginate/HandleResponse tests): - RuntimeContext.Out/OutRaw/OutFormat/OutFormatRaw/OutPartialFailure now build an Emitter and call Success/PartialFailure; emit and outFormat are removed. An adapter maps the returned error back to the legacy outputErrOnce / jq-error stderr / exit-code behavior. - WriteSuccessEnvelope degrades to a thin Emitter.Success delegate; its 8 callers are unchanged. - apiPaginate/servicePaginate stream pages via Emitter.StreamPage; the aggregate and business-error raw-response branches are untouched. - HandleResponse routes its non-JSON structured-response branch through Emitter.Success. Frozen golden fixtures replace the runtime legacy oracles so the differential harness cannot go self-referential after migration. * fix: keep _notice on struct payloads in Emitter's unknown-format fallback printLegacyDataJSON now normalizes via toGeneric first (matching FormatValue), so a struct / named-map payload retains its injected _notice on the unknown-format -> JSON fallback rather than dropping it silently. Add a regression test that fails against the pre-fix path. * refactor: make the Emitter own write failures and stop mutating inputs Route every Emitter stdout path through a render-to-buffer-then-copy helper so a marshal/render failure leaves stdout empty and surfaces a typed internal error (with cause), and a stdout write failure is propagated instead of silently swallowed. Leaf writers gain error-returning Write* cores; the legacy Print*/FormatValue wrappers keep their exact behavior for unmigrated callers. - handleEmitterError now captures every error, not only the jq/safety branches; flip OutRaw's write-error test to assert propagation. - Clone the map before injecting _notice so a caller's payload is never mutated and an existing _notice is never overwritten. - Preserve jq's own typed error (validation/api) on a bad expression or runtime failure; only wrap genuine stdout write failures. - Split tests: normative emitter_contract_test.go vs frozen emitter_legacy_compat_test.go (base SHA recorded, self-update env vars removed). * fix: satisfy license-header and forbidigo lint on the emitter changes - Move the base-SHA note below the copyright header in the renamed legacy-compat test so the license-header check sees a valid header at the top. - Route the leaf wrappers' marshal/format stderr messages through a single legacyStderrf helper (one //nolint:forbidigo) instead of bare os.Stderr, preserving exact legacy behavior for unmigrated direct callers while passing forbidigo; drop the now-unused os imports. * fix: stop legacy CSV wrappers reporting write failures to stderr Align FormatAsCSV/FormatAsCSVPaginated and FormatValue/FormatPage's CSV branch with the other leaf wrappers: report only marshal failures, swallow write failures. Previously they emitted a 'csv write error' for the (empty) line and the JSON-fallback write failures that the pre-refactor code ignored, and mislabeled a JSON write failure as a CSV one. Failure-path only; success output is unchanged (golden double-diff still byte-for-byte).
172 lines
4.3 KiB
Go
172 lines
4.3 KiB
Go
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package output
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"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)
|
|
if err := WriteJSON(w, data); isOutputMarshalError(err) {
|
|
legacyStderrf("json marshal error: %v\n", err)
|
|
}
|
|
}
|
|
|
|
type outputMarshalError struct {
|
|
err error
|
|
}
|
|
|
|
func (e *outputMarshalError) Error() string {
|
|
return e.err.Error()
|
|
}
|
|
|
|
func (e *outputMarshalError) Unwrap() error {
|
|
return e.err
|
|
}
|
|
|
|
func isOutputMarshalError(err error) bool {
|
|
var marshalErr *outputMarshalError
|
|
return errors.As(err, &marshalErr)
|
|
}
|
|
|
|
// legacyStderrf reports a leaf-formatter marshal/format failure on os.Stderr,
|
|
// preserving the pre-Emitter behavior for direct (unmigrated) callers of the
|
|
// Print*/FormatAs* wrappers. The Emitter never uses this — it returns typed
|
|
// errors instead. Removed once the remaining direct callers migrate.
|
|
func legacyStderrf(format string, args ...interface{}) {
|
|
fmt.Fprintf(os.Stderr, format, args...) //nolint:forbidigo // legacy leaf-formatter stderr; removed in the output-ownership follow-up
|
|
}
|
|
|
|
// WriteJSON writes data as formatted JSON to w and returns marshal or write errors.
|
|
func WriteJSON(w io.Writer, data interface{}) error {
|
|
b, err := json.MarshalIndent(data, "", " ")
|
|
if err != nil {
|
|
return &outputMarshalError{err: err}
|
|
}
|
|
_, err = fmt.Fprintln(w, string(b))
|
|
return err
|
|
}
|
|
|
|
// 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{}) {
|
|
if arr, ok := data.([]interface{}); ok {
|
|
for _, item := range arr {
|
|
if err := WriteNDJSON(w, item); isOutputMarshalError(err) {
|
|
legacyStderrf("ndjson marshal error: %v\n", err)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
if err := WriteNDJSON(w, data); isOutputMarshalError(err) {
|
|
legacyStderrf("ndjson marshal error: %v\n", err)
|
|
}
|
|
}
|
|
|
|
// WriteNDJSON writes data as NDJSON and returns marshal or write errors.
|
|
func WriteNDJSON(w io.Writer, data interface{}) error {
|
|
emit := func(item interface{}) error {
|
|
b, err := json.Marshal(item)
|
|
if err != nil {
|
|
return &outputMarshalError{err: err}
|
|
}
|
|
_, err = fmt.Fprintln(w, string(b))
|
|
return err
|
|
}
|
|
if arr, ok := data.([]interface{}); ok {
|
|
for _, item := range arr {
|
|
if err := emit(item); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return 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)
|
|
}
|