Compare commits

...

21 Commits

Author SHA1 Message Date
shanglei
6249392823 chore(output): remove now-unused PrintNdjson
Its callers were routed through the emitter, leaving PrintNdjson unreachable,
which the incremental deadcode CI gate rejects. WriteNDJSON remains for the
emitter path. No behavior change.
2026-07-23 21:05:41 +08:00
shanglei
dc9b6c1799 fix(mail): restore triage table/json rendering via output helpers
Real binary-vs-main comparison caught a regression: `mail +triage`'s default
view (table) rendered as a JSON array. The cause is the emitter routing —
EmitValue(rows, "table") on []map[string]interface{} goes through
emitValue→WriteTable directly, which (unlike WriteFormatted) does not
ExtractItems-normalize the shape, so it fell back to JSON. Restore
output.PrintTable / output.PrintJson for the table and json branches so the
rendered output matches the prior behavior. The filter-schema keeps OutJSON: it
must emit JSON regardless of the command's --format (verified by the
Format="data" test).
2026-07-23 20:29:18 +08:00
shanglei
4e221ed3be refactor(client): PaginateToOutput takes an options struct
PaginateToOutput had 11 positional parameters (writers, callbacks, pagination
knobs), which read poorly at the call sites and invited positional-argument
mistakes. Introduce PaginateOutputOptions and reduce the signature to
(ctx, opts). The two production call sites (cmd/api, cmd/service) now pass named
fields; the pagination tests use a small adapter so each case stays one
statement. No behavior change.
2026-07-23 20:07:49 +08:00
shanglei
ff7b7371fc revert(mail): keep mailbox and sender in watch logs and warnings
The prior change dropped the mailbox from the subscribe/filter progress lines
and the sender from the prompt-injection security warning. That logging content
is the command's own operational/security surface, not the output framework's
concern, so restore it. The unified emitter routing is kept.
2026-07-23 19:55:18 +08:00
shanglei
1ecaf831b1 fix(output): rendered-byte content scan, pretty rendering, block cap; wire remaining commands
Complete the content-safety and output-contract follow-ups across the emitter,
pagination, and the commands that emit directly.

- Content safety scans the exact rendered bytes for table/csv/ndjson/pretty/jq
  and per streamed page, so a match formed only in the rendered output (joined
  table cells, a jq concatenation) cannot slip past block mode. The JSON
  envelope keeps its data scan (JSON punctuation prevents whitespace-joined
  cross-field matches) and still embeds the alert.
- warn pagination writes each page as it arrives; block pagination buffers and
  commits atomically with a 64 MiB cap, failing closed past the limit.
- --format pretty without a dedicated renderer falls back to human-readable
  output rather than JSON; OutPartialFailure keeps the caller's table/csv/
  ndjson/pretty choice while json/jq keep the ok:false envelope.
- auth scopes --format pretty writes business data to stdout and propagates
  write errors through the emitter.
- event +subscribe exposes only json/ndjson (--json kept as an alias, conflict
  checked before run); event/mail-triage/mail-watch/record-markdown/schema
  output now run the unified content-safety scan; mail watch no longer logs
  addresses or sender info.
- calendar/minutes/vc pagination progress moved to stderr so csv/ndjson stdout
  stays parseable; non-list paginated responses keep the requested format.
- wrap the flag-parse error with %w in localfileio path handling.
2026-07-23 19:31:20 +08:00
shanglei
720c9275e5 fix(output): scan rendered bytes so block mode can't be bypassed by field joins
The content-safety scan ran over the structured data, but table joins cells
with whitespace and jq can concatenate fields, so a rule match can form only in
the rendered output — the scan never saw it and block mode still wrote it to
stdout (e.g. cells "ignore" + "previous instructions" render as
"ignore  previous instructions", matching instruction_override).

Scan the exact bytes that will be written instead:
- table, csv, ndjson, pretty, and streamed pages now render into a buffer,
  scan that buffer as full text, and copy to stdout only if the scan does not
  block (a warn-mode alert still goes to stderr).
- the jq path scans the rendered jq output before writing, catching
  expressions like '.data.a + " " + .data.b' that concatenate fields.
- the JSON envelope keeps scanning its data: JSON serialization keeps
  per-field punctuation between values, so a whitespace-joined cross-field
  match cannot form there, and this preserves the embedded content-safety
  alert without a second scan.
- removed the now-unused emit() helper.

Tests: TestEmitterBlockScansRenderedCrossFieldConcatenation covers table
cross-column values, table cross-column keys, a scanned csv render, and a jq
concatenation (all assert ContentSafetyError + empty stdout in block mode). The
api/service streaming content-safety tests now assert the scan sees the
rendered page bytes.
2026-07-23 18:04:13 +08:00
shanglei
037cd47941 fix(output): scan map keys, drop dead scan fn, validate PartialFailure format
Address the pr-review on PR #1998.

- P1 (security): the content-safety scanner walked map VALUES only, so a rule
  match hiding in a map KEY (which json/ndjson/table/csv all emit) slipped past
  block mode — a deterministic structured-output bypass. walk now scans each key
  before recursing into its value.
- P1 (CI): remove the now-unreachable exported ScanRenderedText. The scan path
  moved onto Emitter.scanForSafety, leaving ScanRenderedText dead, which failed
  the required `deadcode` check. ScanForSafety is still used and stays.
- P2: PartialFailure now validates opts.Format like Success/StreamPage and
  returns a typed internal error for an invalid enum instead of silently
  emitting JSON; added to the invalid-format test table.
- P2: the streaming pretty fallback warning said "showing JSON" but the output
  is NDJSON; corrected the message (and its test) to "showing NDJSON instead".

Tests: TestWalk_ScansMapKeys and TestProvider_ScanDetectsInjectionInMapKey
(Scan + ScanFullText) cover the map-key scan; the invalid-format table now
covers PartialFailure.
2026-07-23 17:25:06 +08:00
shanglei
d6afdafd2b fix(output): content-safety full-text capability, format hardening, pretty fallback
Second-round owner review follow-ups on PR #1998.

- P1: --format pretty on a command that has no pretty renderer no longer errors
  after the work already ran. For a write that path mutated remote state, then
  exited non-zero, so automation treated it as a failure and retried, creating
  duplicate resources. The emitter now writes a stderr warning and falls back
  to the JSON envelope (exit 0); StreamPage falls back to NDJSON with a single
  warning. Reads and writes behave identically.

- P1: make full-text content-safety scanning a DETECTABLE capability. Add
  extension/contentsafety.FullTextProvider; block mode requires it and returns
  scan-incomplete (blocked, empty stdout) for any provider that cannot
  guarantee a complete scan. A legacy provider that silently truncates can no
  longer let a match past the truncation point reach stdout.

- P1: structured output (json/table/csv/ndjson) is scanned in full under block
  mode. Per-string 128 KiB truncation and depth-cap stops now surface as
  scan-incomplete and block, instead of emitting data that was only partially
  scanned.

- P2: invalid Format enum values error instead of silently degrading.
  Format.Valid(), String() renders unknown(N) for out-of-range values, and
  Success / StreamPage / WriteFormatted / PaginatedFormatter.WritePage return a
  typed internal error rather than defaulting to JSON or writing nothing.

- P3: the content-safety scan-context factory is an injected dependency rather
  than a mutable package global, so parallel tests cannot interfere.
2026-07-23 16:45:22 +08:00
shanglei
4687e714a4 fix(output): close block-mode scan race; error on pretty without renderer
Address the owner review high-priority items on PR #1998:

- H1: a content-safety scan aborted by context cancellation returned (nil, nil),
  indistinguishable from a clean scan; when the result channel raced ctx.Done()
  the select could pick it and block mode would fail OPEN. scanner.walk now
  returns ctx.Err() so an aborted scan surfaces as an error (block fails closed
  via the existing path), and runContentSafety re-checks ctx.Err() after
  receiving a result as a second layer. Regression tests: a provider that
  returns (nil,nil) only after ctx.Done() is blocked in block mode; walk/scan
  cancellation surface errors.

- H2: emitPretty no longer silently emits JSON when no pretty renderer is
  supplied — it returns a typed validation error (param --format). So
  --format pretty on a shortcut without a renderer now errors instead of
  returning JSON (intentional output-contract tightening; the legacy oracle
  golden's pretty_without_renderer case is updated to the error shape).

- Add a CLI dry-run E2E for the format contract (mixed-case pretty preview,
  unknown format rejected pre-request, error carries category/subtype/param).

- runner jq tests use cmdutil.TestFactory with an isolated config dir.
2026-07-23 15:27:53 +08:00
shanglei
cdc88bd38d docs(output): clarify FullText scan contract; guard OutFormat unknown format
Address Codex review low-priority items:
- extension/contentsafety ScanRequest.FullText now documents that providers
  must scan the full string with no per-string truncation, and the Provider
  interface comment requires implementations to honor it.
- RuntimeContext.OutFormat/OutFormatRaw check ParseFormat's ok and return a
  typed internal error for an unsupported format instead of silently degrading
  to JSON; drop the stale 'validated by ParseFormatStrict' comments (that gate
  only covers framework-injected formats, not self-declared ones).
2026-07-23 14:50:27 +08:00
shanglei
463e4170a4 fix(output): scan full pretty output; block mode fails closed
Address the owner review's blocking finding on PR #1998: the 128 KiB windowed
pretty scan could be bypassed — a match straddling a window boundary (via the
default instruction_override rule's unbounded \s+) matched the full text but
neither window, so block mode still wrote it to stdout.

- Scan the complete rendered text as one string (no windows): correct regex
  semantics, and the []any window round-trip through normalize disappears.
- Add a no-truncate full-text scan path (extcs.ScanRequest.FullText, additive)
  so content past the scanner's 128 KiB per-string cap is still scanned;
  latency stays bounded by the existing 100 ms scan timeout. The structured
  API-response scan path keeps its cap.
- Block mode now FAILS CLOSED when a scan cannot complete (timeout/error/panic):
  nothing is written and a typed ContentSafetyError is returned. warn/off keep
  failing open. This intentionally changes the §0.3 content-safety red line for
  block mode; the legacy oracle golden is updated accordingly.
- Report an unknown --format before the --jq conflict (with the --format param),
  and validate the framework --format on the --print-schema path too.

Regression tests: cross-window instruction_override payload is now blocked;
full-text scan catches matches beyond the per-string cap; regex boundary
semantics preserved; block-mode fail-closed on scan timeout/error.
2026-07-23 14:34:19 +08:00
shanglei
da5b752907 Merge remote-tracking branch 'origin/main' into refactor/output-emitter-followups 2026-07-22 19:22:43 +08:00
Yuxuan Zhao
8f6f8eb0fc test(e2e): declare request identities explicitly (#2004)
* test(e2e): declare request identities explicitly

* test(e2e): skip base workflow without bot credentials
2026-07-22 19:22:08 +08:00
shanglei
35de0b9ee8 Merge remote-tracking branch 'origin/main' into refactor/output-emitter-followups 2026-07-22 17:44:13 +08:00
shanglei
8b30b075b6 Merge remote-tracking branch 'origin/main' into refactor/output-emitter-followups 2026-07-22 17:06:01 +08:00
shanglei
d668d754d5 fix(output): canonicalize shortcut format and align pretty scan window
Further review fixes:
- Normalize the framework-injected --format to its canonical lowercase and
  write it back to the runtime context and the cobra flag, so shortcuts that
  branch on the exact value (e.g. Format == "pretty") behave correctly for
  mixed-case input like --format Pretty, which previously slipped past those
  checks and produced empty output.
- Size the pretty rendered-text scan window to the content-safety scanner's
  native 128 KiB per-string capacity (was 64 KiB) so the windowing is no more
  restrictive than scanning the raw value; keep the 4 KiB overlap for matches
  crossing a window boundary.
2026-07-22 16:31:54 +08:00
shanglei
0a61b41f92 fix(output): close pretty-scan truncation gap and normalize dry-run format
Address code-review findings on the emitter follow-ups:

- Large pretty output is scanned in overlapping 64 KiB windows instead of one
  string, so content past the safety scanner's 128 KiB per-string cap is no
  longer skipped — this was a content-safety bypass reintroduced by the
  buffer-then-scan change.
- Feed the canonical Format.String() into the dry-run path (api/service/
  shortcut) so a mixed-case --format Pretty still renders the plain-text
  preview instead of falling through to the JSON envelope.
- The raw api/service unknown-format error lists only json/ndjson/table/csv,
  not the shortcut-only pretty, so it no longer suggests a value those commands
  reject.
2026-07-22 16:09:06 +08:00
shanglei
ed60f31912 refactor(output): unify api/service pagination into client.PaginateToOutput
apiPaginate and servicePaginate were near-identical; merge them into one shared
client.PaginateToOutput. The two call-site differences are injected: checkErr
(both pass APIClient.CheckResponse) and markErr (cmd/api passes errs.MarkRaw,
cmd/service passes nil). markErr wraps only the PaginateAll / StreamPages /
checkErr errors — never the WriteSuccessEnvelope return — preserving both
commands' exact stdout/stderr bytes and error semantics. Pure refactor.
2026-07-22 15:15:10 +08:00
shanglei
9f22d89112 refactor(output): always warn on stderr when jq may drop a content-safety alert
When --jq is applied, the jq expression can filter the _content_safety_alert
field out of stdout, hiding the warning. Whether a stderr fallback warning was
written used to depend on a caller-set EmitOptions.JQSafetyWarning flag, so the
raw api/service paths warned but shortcut commands did not — the safety alert
was silently lost. Remove the flag and always write the stderr warning when jq
is applied and an alert exists.
2026-07-22 14:37:11 +08:00
shanglei
e0e034c170 refactor(output): scan pretty-rendered output before writing to stdout
The pretty path scanned the structured `data` argument but rendered via an
opaque closure, so a renderer that printed content absent from `data` could
bypass content-safety in block mode. Render pretty output into a buffer, run
the safety scan on the actual rendered text, and only copy to stdout when it
passes — the bytes that reach stdout are now exactly what was scanned. Applies
to both Success's pretty path and StreamPage's pretty branch; json/table/csv/
ndjson are unchanged (they render from the scanned data directly).
2026-07-22 11:39:59 +08:00
shanglei
5393bd6395 refactor(output)!: strict typed Format — reject unknown format & illegal combos
An unknown --format now fails with a typed ValidationError instead of printing a
stderr warning and silently degrading to JSON, and unfulfillable combinations are
rejected at the flag boundary rather than dropped silently.

- Add FormatPretty to the Format enum and ParseFormatStrict, which returns a
  typed validation error (param --format) for any unrecognized format.
- EmitOptions.Format / StreamOptions.Format / Emitter.streamFormat are now the
  typed output.Format; the upstream .String() -> ParseFormat round-trip and the
  Emitter's internal unknown-format fallback (printLegacyDataJSON) are removed.
- Strict parsing runs at the api, service, and shortcut boundaries, before the
  dry-run branch so both dry-run and emit reject an unknown format. The strict
  contract applies only to the framework-injected --format; a shortcut that
  declares its own format flag (base +record-* markdown|json, mail +watch
  json|data) keeps its own enum, validated by validateEnumFlags.
- The raw api/service commands reject --format pretty on the emit path (no
  response pretty renderer) while preserving the dry-run plain-text preview;
  the check runs before confirmation and client init.
- ValidateJqFlags classifies the format via ParseFormat so --jq's JSON-only
  check is case-insensitive and single-sourced: --format JSON --jq no longer
  mis-rejects, while any non-JSON value (including a shortcut's markdown/data)
  still conflicts with --jq.

BREAKING CHANGE: an unknown --format value (e.g. a typo like `--format tabel`)
is now a typed validation error with a non-zero exit code instead of a stderr
warning plus JSON output. Scripts that relied on the unknown-format JSON
fallback must pass a valid format (json, ndjson, table, csv, or pretty).
2026-07-21 22:05:30 +08:00
57 changed files with 3553 additions and 787 deletions

View File

@@ -5,8 +5,6 @@ package api
import (
"context"
"fmt"
"io"
"regexp"
"strings"
@@ -234,6 +232,15 @@ func apiRun(opts *APIOptions) error {
errs.InvalidParam{Name: "--page-all", Reason: "conflicts with --output"},
)
}
// Parse before the dry-run branch so both dry-run and emit reject unknown
// values. Raw API responses accept four formats; pretty remains available
// only for the dry-run request preview handled below.
format, ok := output.ParseFormat(opts.Format)
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown output format %q (want json, ndjson, table, or csv)", opts.Format).
WithParam("--format")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
}
@@ -250,9 +257,17 @@ func apiRun(opts *APIOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts), *fileMeta)
return cmdutil.PrintDryRunWithFile(request, config, dryRunOutputOptions(f, opts, format), *fileMeta)
}
return apiDryRun(f, request, config, opts)
return apiDryRun(f, request, config, opts, format)
}
// pretty is a shortcut-only presentation format; the raw api command has no
// pretty renderer for responses, so reject it before client init rather than
// fall back. (Dry-run keeps its own plain-text pretty preview, handled above.)
if format == output.FormatPretty {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--format pretty is not supported here (use json, ndjson, table, or csv)").
WithParam("--format")
}
// Identity info is now included in the JSON envelope; skip stderr printing.
// cmdutil.PrintIdentity(f.IOStreams.ErrOut, opts.As, config, f.IdentityAutoDetected)
@@ -263,14 +278,20 @@ func apiRun(opts *APIOptions) error {
}
out := f.IOStreams.Out
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
if opts.PageAll {
return apiPaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay})
return client.PaginateToOutput(opts.Ctx, client.PaginateOutputOptions{
Client: ac,
Request: request,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
CommandPath: opts.Cmd.CommandPath(),
Pagination: client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay},
CheckErr: ac.CheckResponse,
MarkErr: errs.MarkRaw,
})
}
resp, err := ac.DoAPI(opts.Ctx, request)
@@ -304,13 +325,13 @@ func apiRun(opts *APIOptions) error {
return nil
}
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts))
func apiDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *APIOptions, format output.Format) error {
return cmdutil.PrintDryRun(request, config, dryRunOutputOptions(f, opts, format))
}
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOutputOptions {
func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions, format output.Format) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: opts.Format,
Format: format.String(),
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
@@ -318,75 +339,3 @@ func dryRunOutputOptions(f *cmdutil.Factory, opts *APIOptions) cmdutil.DryRunOut
ErrOut: f.IOStreams.ErrOut,
}
}
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
}, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
return errs.MarkRaw(apiErr)
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return errs.MarkRaw(err)
}
if apiErr := ac.CheckResponse(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return errs.MarkRaw(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -66,6 +66,23 @@ func apiPaginateRequest() client.RawApiRequest {
}
}
// apiPaginate adapts the positional test calls to PaginateToOutput's options
// struct so each test case stays a single readable statement.
func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pag client.PaginationOptions, checkErr func(interface{}, core.Identity) error, markErr func(error) error) error {
return client.PaginateToOutput(ctx, client.PaginateOutputOptions{
Client: ac,
Request: request,
Format: format,
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Pagination: pag,
CheckErr: checkErr,
MarkErr: markErr,
})
}
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
@@ -112,10 +129,10 @@ func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
}, ac.CheckResponse, errs.MarkRaw)
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
@@ -195,10 +212,10 @@ func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
})
}, ac.CheckResponse, errs.MarkRaw)
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
@@ -239,14 +256,14 @@ func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
if !errors.Is(err, sentinel) {
t.Fatalf("apiPaginate() error = %v, want preserved writer cause", err)
t.Fatalf("PaginateToOutput() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("apiPaginate() problem = %#v, %v; want internal typed error", problem, ok)
t.Fatalf("PaginateToOutput() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
@@ -256,7 +273,7 @@ func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
}
}
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
func TestAPIPaginate_StreamingFormatHonorsNDJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
@@ -271,22 +288,17 @@ func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
if err != nil {
t.Fatalf("apiPaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
const want = "{\"name\":\"Test User\",\"user_id\":\"u123\"}\n"
if got := out.String(); got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
if errOut.Len() != 0 {
t.Fatalf("stderr bytes = %q, want empty", errOut.String())
}
}
@@ -314,10 +326,10 @@ func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
t.Fatal("PaginateToOutput() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
@@ -349,10 +361,10 @@ func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
ac, out, errOut, _ := newAPIPaginateTestHarness(t)
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
if err == nil {
t.Fatal("apiPaginate() error = nil, want transport error")
t.Fatal("PaginateToOutput() error = nil, want transport error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
@@ -379,10 +391,10 @@ func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
})
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, errs.MarkRaw)
if err == nil {
t.Fatal("apiPaginate() error = nil, want business error")
t.Fatal("PaginateToOutput() error = nil, want business error")
}
if !errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)

View File

@@ -118,6 +118,101 @@ func TestApiCmd_DryRunWithJq(t *testing.T) {
}
}
// An unknown --format is a typed validation error, not a silent JSON fallback —
// on both the emit path and (parsed before the dry-run branch) the dry-run path.
// No stub is registered because the command must fail before any API call.
func TestApiCmd_UnknownFormat_Rejected(t *testing.T) {
for _, extra := range [][]string{nil, {"--dry-run"}} {
name := "emit"
if len(extra) > 0 {
name = "dry-run"
}
t.Run(name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs(append([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "bogus"}, extra...))
err := cmd.Execute()
if err == nil {
t.Fatal("expected a validation error for unknown --format")
}
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "unknown output format") {
t.Errorf("error = %v, want unknown-format message", err)
}
if strings.Contains(strings.ToLower(err.Error()), "pretty") {
t.Errorf("error = %v, raw api format choices must exclude pretty", err)
}
if stdout.String() != "" {
t.Errorf("unknown --format must not write stdout, got:\n%s", stdout.String())
}
})
}
}
func TestApiCmd_UnknownFormatPrecedesJqConflict(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{
"GET", "/open-apis/test", "--as", "bot",
"--format", "tabel", "--jq", ".",
})
err := cmd.Execute()
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "unknown output format") {
t.Fatalf("error = %v, want unknown-format message", err)
}
if strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("error = %v, unknown format should be reported before jq conflict", err)
}
if stdout.Len() != 0 {
t.Fatalf("unknown --format wrote stdout:\n%s", stdout.String())
}
}
// pretty is shortcut-only: the raw api command rejects it on the emit path
// (before client init) but keeps the dry-run plain-text preview.
func TestApiCmd_Pretty_RejectedOnEmit(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "pretty"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected a validation error for --format pretty on the emit path")
}
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "pretty") {
t.Errorf("error = %v, want pretty-not-supported message", err)
}
if stdout.String() != "" {
t.Errorf("rejected --format pretty must not write stdout, got:\n%s", stdout.String())
}
}
func TestApiCmd_MixedCasePretty_PreservedOnDryRun(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--format", "Pretty", "--dry-run"})
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run --format pretty must be accepted, got: %v", err)
}
if !strings.Contains(stdout.String(), "# dry-run: request not sent") {
t.Fatalf("dry-run --format pretty lost its plain-text preview, stdout:\n%s", stdout.String())
}
}
// Regression: --params null parses to a nil map; writing page_size onto it must
// not panic. Symmetric to the typed-flag overlay path in cmd/service — both
// write into the map ParseJSONMap returns.
@@ -404,7 +499,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
}
}
func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
func TestApiCmd_PageAll_NonBatchAPI_HonorsNDJSON(t *testing.T) {
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-pageall1", AppSecret: "test-secret-pageall1", Brand: core.BrandFeishu,
})
@@ -427,24 +522,15 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should print fallback warning to stderr
if !strings.Contains(stderr.String(), "warning: this API does not return a list") {
t.Error("expected fallback warning in stderr")
if strings.Contains(stderr.String(), "falling back") {
t.Fatalf("stderr contains format fallback warning: %q", stderr.String())
}
if !strings.Contains(stderr.String(), "falling back to json") {
t.Error("expected 'falling back to json' in stderr")
}
// Should output JSON result to stdout
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("invalid JSON output: %v\n%s", err, stdout.String())
t.Fatalf("invalid NDJSON object: %v\n%s", err, stdout.String())
}
data, ok := got["data"].(map[string]interface{})
if got["ok"] != true || got["identity"] != "bot" || !ok || data["user_id"] != "u123" {
t.Fatalf("unexpected fallback envelope: %#v", got)
}
if _, hasCode := got["code"]; hasCode {
t.Fatalf("fallback success envelope leaked outer code: %s", stdout.String())
if got["user_id"] != "u123" || got["name"] != "Test User" {
t.Fatalf("unexpected NDJSON object: %#v", got)
}
}
@@ -621,6 +707,10 @@ func (p *apiContentSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest
return &extcs.Alert{Provider: "api-test", MatchedRules: []string{"pagination"}}, nil
}
func (p *apiContentSafetyProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &apiContentSafetyProvider{}
@@ -654,12 +744,12 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
data, ok := provider.data.(map[string]interface{})
data, ok := provider.data.(string)
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
t.Fatalf("scanned data type = %T, want rendered JSON string", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
if strings.Contains(data, `"code"`) || !strings.Contains(data, `"data"`) {
t.Fatalf("scanned JSON should be the success envelope without an API code, got %q", data)
}
var got map[string]interface{}
@@ -705,9 +795,11 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
if provider.path != "api" {
t.Fatalf("scan path = %q, want api", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
// Streaming now scans the exact rendered page bytes (not the structured
// item) so a rule match formed only in the rendered output cannot slip past.
scanned, ok := provider.data.(string)
if !ok || !strings.Contains(scanned, `"id":"1"`) {
t.Fatalf("scanned data = %#v, want rendered ndjson page text", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from api-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
@@ -767,11 +859,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
if out != "" {
t.Fatalf("blocked complete stream was written before safety block: %s", out)
}
}
@@ -786,6 +875,18 @@ func requireProblem(t *testing.T, err error, category errs.Category, subtype err
}
}
func requireValidationParam(t *testing.T, err error, param string) {
t.Helper()
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, 0)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Param != param {
t.Fatalf("Param = %q, want %q", validationErr.Param, param)
}
}
func TestNormalisePath_StripsQueryAndFragment(t *testing.T) {
for _, tt := range []struct {
name string

View File

@@ -381,6 +381,34 @@ func TestAuthScopesCmd_JSONFlagForcesJSONFormat(t *testing.T) {
}
}
func TestAuthScopesCmd_RejectsUnknownFormat(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
})
runCalled := false
cmd := NewCmdAuthScopes(f, func(*ScopesOptions) error {
runCalled = true
return nil
})
cmd.SetArgs([]string{"--format", "tabel"})
err := cmd.Execute()
if err == nil {
t.Fatal("expected invalid format error")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("error = %T, want *errs.ValidationError", err)
}
if validationErr.Category != errs.CategoryValidation || validationErr.Subtype != errs.SubtypeInvalidArgument || validationErr.Param != "--format" {
t.Fatalf("validation error = %#v; want validation/invalid_argument with --format", validationErr)
}
if runCalled {
t.Fatal("auth scopes runner was called for an invalid format")
}
}
func TestAuthScopesRun_UsesTenantAccessTokenFromCredentialProvider(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "", Brand: core.BrandFeishu,

View File

@@ -6,6 +6,8 @@ package auth
import (
"context"
"fmt"
"io"
"strings"
"github.com/spf13/cobra"
@@ -33,6 +35,13 @@ func NewCmdAuthScopes(f *cmdutil.Factory, runF func(*ScopesOptions) error) *cobr
opts.Ctx = cmd.Context()
if opts.JSON {
opts.Format = "json"
} else {
opts.Format = strings.ToLower(strings.TrimSpace(opts.Format))
if opts.Format != "json" && opts.Format != "pretty" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown output format %q (want json or pretty)", opts.Format).
WithParam("--format")
}
}
if runF != nil {
return runF(opts)
@@ -74,20 +83,36 @@ func authScopesRun(opts *ScopesOptions) error {
return errs.NewInternalError(errs.SubtypeSDKError,
"failed to get app scope info: %v", err).WithCause(err)
}
data := map[string]interface{}{
"appId": config.AppID,
"brand": config.Brand,
"tokenType": "user",
"userScopes": appInfo.UserScopes,
"count": len(appInfo.UserScopes),
}
emitter := output.NewEmitter(output.EmitterConfig{
Out: f.IOStreams.Out,
ErrOut: f.IOStreams.ErrOut,
CommandPath: "lark-cli auth scopes",
})
if opts.Format == "pretty" {
fmt.Fprintf(f.IOStreams.ErrOut, "App ID: %s\n", config.AppID)
fmt.Fprintf(f.IOStreams.ErrOut, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes))
for _, s := range appInfo.UserScopes {
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n", s)
}
} else {
output.PrintJson(f.IOStreams.Out, map[string]interface{}{
"appId": config.AppID,
"brand": config.Brand,
"tokenType": "user",
"userScopes": appInfo.UserScopes,
"count": len(appInfo.UserScopes),
return emitter.Value(data, output.StreamOptions{
Format: output.FormatPretty,
Pretty: func(w io.Writer, _ bool) error {
if _, err := fmt.Fprintf(w, "App ID: %s\n", config.AppID); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "Enabled scopes (%d):\n\n", len(appInfo.UserScopes)); err != nil {
return err
}
for _, scope := range appInfo.UserScopes {
if _, err := fmt.Fprintf(w, " • %s\n", scope); err != nil {
return err
}
}
return nil
},
})
}
return nil
return emitter.Value(data, output.StreamOptions{Format: output.FormatJSON})
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
@@ -43,6 +44,36 @@ func scopesTestFactory(t *testing.T) *ScopesOptions {
}
}
func TestAuthScopesRunPrettyWritesBusinessDataToStdout(t *testing.T) {
previous := getAppInfoFn
getAppInfoFn = func(context.Context, *cmdutil.Factory, string) (*appInfo, error) {
return &appInfo{UserScopes: []string{"im:message"}}, nil
}
t.Cleanup(func() { getAppInfoFn = previous })
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
})
err := authScopesRun(&ScopesOptions{
Factory: f,
Ctx: context.Background(),
Format: "pretty",
})
if err != nil {
t.Fatalf("authScopesRun() error = %v", err)
}
for _, want := range []string{"App ID: test-app", "Enabled scopes (1)", "im:message"} {
if !strings.Contains(stdout.String(), want) {
t.Fatalf("stdout missing %q: %s", want, stdout.String())
}
if strings.Contains(stderr.String(), want) {
t.Fatalf("stderr contains business data %q: %s", want, stderr.String())
}
}
}
// TestAuthScopesRun_NetworkErrorPassedThrough pins that a typed NetworkError
// surfaced by the dependency is not re-classified as PermissionError —
// re-auth does not fix DNS / transport failures and blanket-wrapping them

View File

@@ -6,7 +6,6 @@ package service
import (
"context"
"fmt"
"io"
"sort"
"strings"
@@ -380,6 +379,15 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if opts.PageAll && opts.Output != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output and --page-all are mutually exclusive").WithParam("--output")
}
// Parse before the dry-run branch so both dry-run and emit reject unknown
// values. Raw service responses accept four formats; pretty remains available
// only for the dry-run request preview handled below.
format, ok := output.ParseFormat(opts.Format)
if !ok {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown output format %q (want json, ndjson, table, or csv)", opts.Format).
WithParam("--format")
}
if err := output.ValidateJqFlags(opts.JqExpr, opts.Output, opts.Format); err != nil {
return err
}
@@ -403,9 +411,18 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
if opts.DryRun {
if fileMeta != nil {
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts), *fileMeta)
return cmdutil.PrintDryRunWithFile(request, config, serviceDryRunOutputOptions(f, opts, format), *fileMeta)
}
return serviceDryRun(f, request, config, opts)
return serviceDryRun(f, request, config, opts, format)
}
// pretty is a shortcut-only presentation format; the raw service command has
// no pretty renderer for responses, so reject it before the confirmation and
// client init rather than fall back. (Dry-run keeps its own plain-text pretty
// preview, handled above.)
if format == output.FormatPretty {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--format pretty is not supported here (use json, ndjson, table, or csv)").
WithParam("--format")
}
if opts.Method.Risk == cmdutil.RiskHighRiskWrite {
@@ -420,10 +437,6 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
}
out := f.IOStreams.Out
format, formatOK := output.ParseFormat(opts.Format)
if !formatOK {
fmt.Fprintf(f.IOStreams.ErrOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
// Scope-insufficient (99991679) and all other Lark API codes route through
// errclass.BuildAPIError via ac.CheckResponse, producing *errs.PermissionError
@@ -431,8 +444,18 @@ func serviceMethodRun(opts *ServiceMethodOptions) error {
checkErr := ac.CheckResponse
if opts.PageAll {
return servicePaginate(opts.Ctx, ac, request, format, opts.JqExpr, out, f.IOStreams.ErrOut, opts.Cmd.CommandPath(),
client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay}, checkErr)
return client.PaginateToOutput(opts.Ctx, client.PaginateOutputOptions{
Client: ac,
Request: request,
Format: format,
JqExpr: opts.JqExpr,
Out: out,
ErrOut: f.IOStreams.ErrOut,
CommandPath: opts.Cmd.CommandPath(),
Pagination: client.PaginationOptions{PageLimit: opts.PageLimit, PageDelay: opts.PageDelay},
CheckErr: checkErr,
MarkErr: nil,
})
}
resp, err := ac.DoAPI(opts.Ctx, request)
@@ -667,13 +690,13 @@ func buildServiceRequest(opts *ServiceMethodOptions) (client.RawApiRequest, *cmd
return request, nil, nil
}
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions) error {
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts))
func serviceDryRun(f *cmdutil.Factory, request client.RawApiRequest, config *core.CliConfig, opts *ServiceMethodOptions, format output.Format) error {
return cmdutil.PrintDryRun(request, config, serviceDryRunOutputOptions(f, opts, format))
}
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions) cmdutil.DryRunOutputOptions {
func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions, format output.Format) cmdutil.DryRunOutputOptions {
return cmdutil.DryRunOutputOptions{
Format: opts.Format,
Format: format.String(),
JqExpr: opts.JqExpr,
CommandPath: opts.Cmd.CommandPath(),
Identity: opts.As,
@@ -681,75 +704,3 @@ func serviceDryRunOutputOptions(f *cmdutil.Factory, opts *ServiceMethodOptions)
ErrOut: f.IOStreams.ErrOut,
}
}
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pagOpts client.PaginationOptions, checkErr func(interface{}, core.Identity) error) error {
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
// Streaming formats intentionally emit each page after that page has
// passed safety scanning. A later page may still fail, so callers
// must use the exit code to distinguish complete vs partial output.
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
}, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
return apiErr
}
if !hasItems {
fmt.Fprintf(errOut, "warning: this API does not return a list, format %q is not supported, falling back to json\n", format)
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return err
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
output.FormatValue(out, result, output.FormatJSON)
return apiErr
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -66,6 +66,23 @@ func servicePaginateRequest() client.RawApiRequest {
}
}
// servicePaginate adapts the positional test calls to PaginateToOutput's options
// struct so each test case stays a single readable statement.
func servicePaginate(ctx context.Context, ac *client.APIClient, request client.RawApiRequest, format output.Format, jqExpr string, out, errOut io.Writer, commandPath string, pag client.PaginationOptions, checkErr func(interface{}, core.Identity) error, markErr func(error) error) error {
return client.PaginateToOutput(ctx, client.PaginateOutputOptions{
Client: ac,
Request: request,
Format: format,
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Pagination: pag,
CheckErr: checkErr,
MarkErr: markErr,
})
}
func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
t.Helper()
wantBytes, err := json.MarshalIndent(want, "", " ")
@@ -112,10 +129,10 @@ func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) {
output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse)
}, ac.CheckResponse, nil)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
if calls != 3 {
t.Fatalf("pagination requests = %d, want 3", calls)
@@ -195,10 +212,10 @@ func TestServicePaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
tt.format, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
PageLimit: 10,
PageDelay: -1,
}, ac.CheckResponse)
}, ac.CheckResponse, nil)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
if got := out.String(); got != tt.want {
t.Fatalf("stdout byte mismatch\ngot (%d bytes):\n%q\nwant (%d bytes):\n%q", len(got), got, len(tt.want), tt.want)
@@ -239,14 +256,14 @@ func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse)
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse, nil)
if !errors.Is(err, sentinel) {
t.Fatalf("servicePaginate() error = %v, want preserved writer cause", err)
t.Fatalf("PaginateToOutput() error = %v, want preserved writer cause", err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal {
t.Fatalf("servicePaginate() problem = %#v, %v; want internal typed error", problem, ok)
t.Fatalf("PaginateToOutput() problem = %#v, %v; want internal typed error", problem, ok)
}
if calls != 2 {
t.Fatalf("pagination requests = %d, want 2", calls)
@@ -256,7 +273,7 @@ func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
}
}
func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
func TestServicePaginate_StreamingFormatHonorsNDJSONWithoutList(t *testing.T) {
ac, out, errOut, reg := newServicePaginateTestHarness(t)
reg.Register(&httpmock.Stub{
URL: "/open-apis/test/v1/items",
@@ -272,22 +289,17 @@ func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T)
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items get",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
if err != nil {
t.Fatalf("servicePaginate() error = %v, want nil", err)
t.Fatalf("PaginateToOutput() error = %v, want nil", err)
}
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
OK: true,
Identity: "bot",
Data: map[string]interface{}{
"name": "Test User",
"user_id": "u123",
},
})
wantWarning := "warning: this API does not return a list, format \"ndjson\" is not supported, falling back to json\n"
if got := errOut.String(); got != wantWarning {
t.Fatalf("stderr bytes = %q, want %q", got, wantWarning)
const want = "{\"name\":\"Test User\",\"user_id\":\"u123\"}\n"
if got := out.String(); got != want {
t.Fatalf("stdout bytes = %q, want %q", got, want)
}
if errOut.Len() != 0 {
t.Fatalf("stderr bytes = %q, want empty", errOut.String())
}
}
@@ -316,10 +328,10 @@ func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) {
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
if err == nil {
t.Fatal("servicePaginate() error = nil, want business error")
t.Fatal("PaginateToOutput() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
@@ -352,10 +364,10 @@ func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) {
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
if err == nil {
t.Fatal("servicePaginate() error = nil, want transport error")
t.Fatal("PaginateToOutput() error = nil, want transport error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
@@ -383,10 +395,10 @@ func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) {
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse, nil)
if err == nil {
t.Fatal("servicePaginate() error = nil, want business error")
t.Fatal("PaginateToOutput() error = nil, want business error")
}
if errs.IsRaw(err) {
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")

View File

@@ -257,6 +257,21 @@ func TestServiceMethod_DryRunWithJq(t *testing.T) {
}
}
func TestServiceMethod_DryRunMixedCasePrettyUsesPlainTextPreview(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, testConfig)
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--dry-run", "--format", "Pretty"})
if err := cmd.Execute(); err != nil {
t.Fatalf("dry-run --format Pretty must be accepted, got: %v", err)
}
if !strings.Contains(stdout.String(), "# dry-run: request not sent") {
t.Fatalf("dry-run --format Pretty lost its plain-text preview, stdout:\n%s", stdout.String())
}
}
func TestServiceMethod_PathParamRejectsTraversal(t *testing.T) {
tests := []struct {
name string
@@ -525,6 +540,10 @@ func (p *serviceContentSafetyProvider) Scan(_ context.Context, req extcs.ScanReq
return &extcs.Alert{Provider: "service-test", MatchedRules: []string{"pagination"}}, nil
}
func (p *serviceContentSafetyProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
provider := &serviceContentSafetyProvider{}
@@ -561,12 +580,12 @@ func TestServiceMethod_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
data, ok := provider.data.(map[string]interface{})
data, ok := provider.data.(string)
if !ok {
t.Fatalf("scanned data type = %T, want map", provider.data)
t.Fatalf("scanned data type = %T, want rendered JSON string", provider.data)
}
if _, hasCode := data["code"]; hasCode {
t.Fatalf("scanned data should be business data only, got %#v", data)
if strings.Contains(data, `"code"`) || !strings.Contains(data, `"data"`) {
t.Fatalf("scanned JSON should be the success envelope without an API code, got %q", data)
}
var got map[string]interface{}
@@ -615,9 +634,11 @@ func TestServiceMethod_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
if provider.path != "list" {
t.Fatalf("scan path = %q, want list", provider.path)
}
items, ok := provider.data.([]interface{})
if !ok || len(items) != 1 {
t.Fatalf("scanned data = %#v, want one streamed item", provider.data)
// Streaming now scans the exact rendered page bytes (not the structured
// item) so a rule match formed only in the rendered output cannot slip past.
scanned, ok := provider.data.(string)
if !ok || !strings.Contains(scanned, `"id":"1"`) {
t.Fatalf("scanned data = %#v, want rendered ndjson page text", provider.data)
}
if !strings.Contains(stderr.String(), "warning: content safety alert from service-test") {
t.Fatalf("expected content safety warning on stderr, got: %s", stderr.String())
@@ -680,11 +701,8 @@ func TestServiceMethod_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
t.Fatalf("rules = %v, want [pagination]", safetyErr.Rules)
}
out := stdout.String()
if !strings.Contains(out, "safe-page") {
t.Fatalf("expected earlier safe page to remain streamed, got: %s", out)
}
if strings.Contains(out, "blocked-page") {
t.Fatalf("blocked page was written before safety block: %s", out)
if out != "" {
t.Fatalf("blocked complete stream was written before safety block: %s", out)
}
}
@@ -795,26 +813,81 @@ func TestServiceMethod_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T)
}
}
func TestServiceMethod_UnknownFormat_Warning(t *testing.T) {
f, _, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
func TestServiceMethod_UnknownFormat_Rejected(t *testing.T) {
// No stub is registered: the unknown --format must be rejected before any
// API call is made.
f, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
})
reg.Register(&httpmock.Stub{
URL: "/open-apis/svc/v1/items",
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{}},
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{}})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "unknown"})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
// An unknown --format is a typed validation error, not a silent JSON fallback.
err := cmd.Execute()
if err == nil {
t.Fatal("expected a validation error for unknown --format")
}
if !strings.Contains(stderr.String(), "warning: unknown format") {
t.Errorf("expected format warning in stderr, got:\n%s", stderr.String())
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "unknown output format") {
t.Errorf("error = %v, want unknown-format message", err)
}
if strings.Contains(strings.ToLower(err.Error()), "pretty") {
t.Errorf("error = %v, raw service format choices must exclude pretty", err)
}
if stdout.String() != "" {
t.Errorf("unknown --format must not write stdout, got:\n%s", stdout.String())
}
// The old degrade-to-JSON warning must be gone, not merely accompanied by an error.
if strings.Contains(stderr.String(), "falling back to json") {
t.Errorf("unknown --format must not emit the legacy fallback warning, got stderr:\n%s", stderr.String())
}
}
func TestServiceMethod_UnknownFormatPrecedesJqConflict(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{
"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{},
})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "tabel", "--jq", "."})
err := cmd.Execute()
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "unknown output format") {
t.Fatalf("error = %v, want unknown-format message", err)
}
if strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("error = %v, unknown format should be reported before jq conflict", err)
}
if stdout.Len() != 0 {
t.Fatalf("unknown --format wrote stdout:\n%s", stdout.String())
}
}
func TestServiceMethod_PrettyRejectedOnEmit(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app-fmt", AppSecret: "test-secret-fmt", Brand: core.BrandFeishu,
})
spec := meta.ServiceFromMap(map[string]interface{}{"name": "svc", "servicePath": "/open-apis/svc/v1"})
method := meta.FromMap(map[string]interface{}{
"path": "items", "httpMethod": "GET", "parameters": map[string]interface{}{},
})
cmd := NewCmdServiceMethod(f, spec, method, "list", "items", nil)
cmd.SetArgs([]string{"--as", "bot", "--format", "pretty"})
err := cmd.Execute()
requireValidationParam(t, err, "--format")
if !strings.Contains(err.Error(), "pretty") {
t.Fatalf("error = %v, want pretty-not-supported message", err)
}
if stdout.Len() != 0 {
t.Fatalf("rejected --format pretty wrote stdout:\n%s", stdout.String())
}
}
@@ -1028,6 +1101,18 @@ func requireProblem(t *testing.T, err error, category errs.Category, subtype err
}
}
func requireValidationParam(t *testing.T, err error, param string) {
t.Helper()
requireProblem(t, err, errs.CategoryValidation, errs.SubtypeInvalidArgument, 0)
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err)
}
if validationErr.Param != param {
t.Fatalf("Param = %q, want %q", validationErr.Param, param)
}
}
// ── file upload ──
func imImageMethod() meta.Method {

View File

@@ -9,17 +9,30 @@ import (
)
// Provider scans parsed response data for content-safety issues.
// Implementations must be safe for concurrent use.
// Implementations must be safe for concurrent use. Scan may be a best-effort
// scan with bounded string length or nesting depth.
type Provider interface {
Name() string
Scan(ctx context.Context, req ScanRequest) (*Alert, error)
}
// FullTextProvider is a Provider that guarantees a complete scan of Data with
// NO per-string or depth truncation. Block mode requires this capability so a
// match anywhere in the output cannot slip past a truncation boundary.
type FullTextProvider interface {
Provider
ScanFullText(ctx context.Context, req ScanRequest) (*Alert, error)
}
// ScanRequest carries the data to scan.
type ScanRequest struct {
Path string // normalized command path (e.g. "im.messages_search")
Data any // parsed response data (generic JSON shape)
ErrOut io.Writer // stderr for provider-level notices (e.g. lazy-config creation)
// FullText marks Data as one complete rendered-output string. It remains a
// compatibility hint for Provider.Scan; block mode calls
// FullTextProvider.ScanFullText to enforce complete scanning.
FullText bool
}
// Alert holds the result of a content-safety scan that detected issues.

View File

@@ -29,6 +29,14 @@ func (s *stubProvider) Scan(_ context.Context, _ ScanRequest) (*Alert, error) {
return &Alert{Provider: "stub", MatchedRules: []string{"test"}}, nil
}
type fullTextStubProvider struct {
stubProvider
}
func (s *fullTextStubProvider) ScanFullText(ctx context.Context, req ScanRequest) (*Alert, error) {
return s.Scan(ctx, req)
}
func TestProviderInterface(t *testing.T) {
var p Provider = &stubProvider{}
if p.Name() != "stub" {
@@ -43,6 +51,17 @@ func TestProviderInterface(t *testing.T) {
}
}
func TestFullTextProviderInterface(t *testing.T) {
var p FullTextProvider = &fullTextStubProvider{}
alert, err := p.ScanFullText(context.Background(), ScanRequest{Path: "test", Data: "full", ErrOut: io.Discard})
if err != nil {
t.Fatalf("ScanFullText() error = %v", err)
}
if alert.Provider != "stub" {
t.Errorf("alert.Provider = %q, want %q", alert.Provider, "stub")
}
}
func TestRegistryLastWriteWins(t *testing.T) {
mu.Lock()
old := provider

View File

@@ -711,3 +711,19 @@ func TestCallAPI_ParseJSONFailureWrapsAsAPI(t *testing.T) {
t.Errorf("ExitCodeOf = %d, want %d (internal)", output.ExitCodeOf(err), output.ExitInternal)
}
}
func TestPaginateToOutputRejectsUnsupportedInternalFormat(t *testing.T) {
for _, format := range []output.Format{output.FormatPretty, output.Format(99)} {
err := PaginateToOutput(context.Background(), PaginateOutputOptions{
Request: RawApiRequest{},
Format: format,
Out: io.Discard,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture",
})
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("format %q error = %T, want *errs.InternalError", format, err)
}
}
}

View File

@@ -0,0 +1,129 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package client
import (
"context"
"io"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
)
// PaginateOutputOptions bundles the inputs for PaginateToOutput. Grouping the
// writers, callbacks, and pagination knobs into one struct keeps the call sites
// readable and avoids positional-argument mistakes across the many parameters.
type PaginateOutputOptions struct {
Client *APIClient
Request RawApiRequest
Format output.Format
JqExpr string
Out io.Writer
ErrOut io.Writer
CommandPath string
Pagination PaginationOptions
CheckErr func(interface{}, core.Identity) error
MarkErr func(error) error
}
// PaginateToOutput fetches all requested pages and emits them in the selected format.
func PaginateToOutput(ctx context.Context, opts PaginateOutputOptions) error {
ac := opts.Client
request := opts.Request
format := opts.Format
jqExpr := opts.JqExpr
out := opts.Out
errOut := opts.ErrOut
commandPath := opts.CommandPath
pagOpts := opts.Pagination
checkErr := opts.CheckErr
markErr := opts.MarkErr
if !format.Valid() || format == output.FormatPretty {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unsupported pagination output format %q", format)
}
if markErr == nil {
markErr = func(err error) error { return err }
}
if pagOpts.Identity == "" {
pagOpts.Identity = request.As
}
emitValue := func(data interface{}, valueFormat output.Format) error {
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
})
return emitter.Value(data, output.StreamOptions{Format: valueFormat})
}
// When jq is set, always aggregate all pages then filter.
if jqExpr != "" {
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return markErr(err)
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
if emitErr := emitValue(result, output.FormatJSON); emitErr != nil {
return markErr(emitErr)
}
return markErr(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
JqExpr: jqExpr,
Out: out,
ErrOut: errOut,
})
}
switch format {
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
emitter := output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
NoticeProvider: output.GetNotice,
})
result, hasItems, err := ac.StreamPages(ctx, request, func(items []interface{}) error {
return emitter.StreamPage(items, output.StreamOptions{Format: format})
}, pagOpts)
if err != nil && errs.IsContentSafety(err) {
return markErr(err)
}
if finishErr := emitter.FinishStream(); finishErr != nil {
return markErr(finishErr)
}
if err != nil {
return markErr(err)
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
return markErr(apiErr)
}
if !hasItems {
return emitter.Value(output.SuccessEnvelopeData(result), output.StreamOptions{Format: format})
}
return nil
default:
result, err := ac.PaginateAll(ctx, request, pagOpts)
if err != nil {
return markErr(err)
}
if apiErr := checkErr(result, pagOpts.Identity); apiErr != nil {
if emitErr := emitValue(result, output.FormatJSON); emitErr != nil {
return markErr(emitErr)
}
return markErr(apiErr)
}
return output.WriteSuccessEnvelope(output.SuccessEnvelopeData(result), output.SuccessEnvelopeOptions{
CommandPath: commandPath,
Identity: string(pagOpts.Identity),
Out: out,
ErrOut: errOut,
})
}
}

View File

@@ -139,7 +139,7 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
Identity: string(identity),
NoticeProvider: output.GetNotice,
})
return emitter.Success(result, output.EmitOptions{Format: opts.Format.String()})
return emitter.Success(result, output.EmitOptions{Format: opts.Format})
}
// Non-JSON (binary) responses.

View File

@@ -4,6 +4,7 @@
package output
import (
"context"
"errors"
"fmt"
"io"
@@ -15,17 +16,19 @@ import (
// ScanResult holds the output of ScanForSafety.
type ScanResult struct {
Alert *extcs.Alert
Blocked bool
BlockErr error
Alert *extcs.Alert
Blocked bool
BlockErr error
scanFailed bool
}
// 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.
// ScanForSafety scans structured response data.
func ScanForSafety(cmdPath string, data any, errOut io.Writer) ScanResult {
alert, csErr := runContentSafety(cmdPath, data, errOut)
return scanForSafetyMode(cmdPath, data, errOut, false, modeFromEnv(errOut), defaultContentSafetyContext)
}
func scanForSafetyMode(cmdPath string, data any, errOut io.Writer, fullText bool, m mode, newScanContext scanContextFactory) ScanResult {
alert, csErr := runContentSafety(cmdPath, data, errOut, fullText, m, newScanContext)
if errors.Is(csErr, errBlocked) {
return ScanResult{
Alert: alert,
@@ -33,10 +36,18 @@ func ScanForSafety(cmdPath string, data any, errOut io.Writer) ScanResult {
BlockErr: wrapBlockError(alert),
}
}
if errors.Is(csErr, errScanIncomplete) {
return ScanResult{
Blocked: true,
BlockErr: wrapScanIncompleteError(csErr),
}
}
if errors.Is(csErr, errScanFailed) {
return ScanResult{scanFailed: true}
}
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 {
@@ -48,8 +59,16 @@ func wrapBlockError(alert *extcs.Alert) error {
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 wrapScanIncompleteError(cause error) error {
message := "content-safety scan did not complete; blocked (block mode)"
if errors.Is(cause, context.DeadlineExceeded) {
message = "content-safety scan did not complete in time; blocked (block mode)"
}
return errs.NewContentSafetyError(errs.SubtypeContentSafety, "%s", message).
WithCause(cause)
}
// WriteAlertWarning writes a content-safety warning.
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) error {
if alert == nil {
return nil

View File

@@ -6,6 +6,7 @@ package output
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
@@ -24,11 +25,15 @@ const (
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.
// scanTimeout also bounds untruncated rendered-text scans.
const scanTimeout = 100 * time.Millisecond
type scanContextFactory func() (context.Context, context.CancelFunc)
func defaultContentSafetyContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), scanTimeout)
}
// modeFromEnv reads LARKSUITE_CLI_CONTENT_SAFETY_MODE.
func modeFromEnv(errOut io.Writer) mode {
raw := strings.TrimSpace(os.Getenv(envvars.CliContentSafetyMode))
@@ -66,11 +71,13 @@ func normalizeCommandPath(cobraPath string) string {
return strings.Join(segs, ".")
}
var errBlocked = fmt.Errorf("content safety blocked")
var (
errBlocked = errors.New("content safety blocked")
errScanFailed = errors.New("content safety scan failed")
errScanIncomplete = errors.New("content safety scan incomplete")
)
// 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)
func runContentSafety(cobraPath string, data any, errOut io.Writer, fullText bool, m mode, newScanContext scanContextFactory) (*extcs.Alert, error) {
if m == modeOff {
return nil, nil
}
@@ -85,17 +92,28 @@ func runContentSafety(cobraPath string, data any, errOut io.Writer) (*extcs.Aler
return nil, nil
}
scan := p.Scan
if m == modeBlock {
fullTextProvider, ok := p.(extcs.FullTextProvider)
if !ok {
return nil, fmt.Errorf("%w: provider %q does not support complete scans",
errScanIncomplete, p.Name())
}
scan = fullTextProvider.ScanFullText
}
type result struct {
alert *extcs.Alert
err error
}
ch := make(chan result, 1)
ctx, cancel := context.WithTimeout(context.Background(), scanTimeout)
if newScanContext == nil {
newScanContext = defaultContentSafetyContext
}
ctx, cancel := newScanContext()
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.
// A timed-out provider may outlive this call, so it cannot share errOut.
scanErrBuf := &bytes.Buffer{}
go func() {
defer func() {
@@ -103,7 +121,12 @@ func runContentSafety(cobraPath string, data any, errOut io.Writer) (*extcs.Aler
ch <- result{nil, fmt.Errorf("content safety panic: %v", r)}
}
}()
a, e := p.Scan(ctx, extcs.ScanRequest{Path: cmdPath, Data: data, ErrOut: scanErrBuf})
a, e := scan(ctx, extcs.ScanRequest{
Path: cmdPath,
Data: data,
ErrOut: scanErrBuf,
FullText: fullText,
})
ch <- result{a, e}
}()
@@ -113,13 +136,22 @@ func runContentSafety(cobraPath string, data any, errOut io.Writer) (*extcs.Aler
if scanErrBuf.Len() > 0 {
_, _ = io.Copy(errOut, scanErrBuf)
}
if ctx.Err() != nil && m == modeBlock {
return nil, fmt.Errorf("%w: %w", errScanIncomplete, ctx.Err())
}
case <-ctx.Done():
return nil, nil // timeout, fail-open; scanErrBuf stays with the goroutine
if m == modeBlock {
return nil, fmt.Errorf("%w: %w", errScanIncomplete, ctx.Err())
}
return nil, fmt.Errorf("%w: %w", errScanFailed, ctx.Err())
}
if res.err != nil {
fmt.Fprintf(errOut, "warning: content safety scan error: %v\n", res.err)
return nil, nil // fail-open
if m == modeBlock {
return nil, fmt.Errorf("%w: %w", errScanIncomplete, res.err)
}
return nil, fmt.Errorf("%w: %w", errScanFailed, res.err)
}
if res.alert == nil {
return nil, nil

View File

@@ -8,6 +8,7 @@ import (
"context"
"errors"
"strings"
"sync/atomic"
"testing"
"time"
@@ -22,11 +23,70 @@ type mockProvider struct {
err error
}
type resultFirstCanceledContext struct {
selectDone chan struct{}
providerDone chan struct{}
selectWaiting chan struct{}
doneCallCounter atomic.Int32
}
func newResultFirstCanceledContext() *resultFirstCanceledContext {
providerDone := make(chan struct{})
close(providerDone)
return &resultFirstCanceledContext{
selectDone: make(chan struct{}),
providerDone: providerDone,
selectWaiting: make(chan struct{}),
}
}
func (c *resultFirstCanceledContext) Deadline() (time.Time, bool) {
return time.Time{}, false
}
func (c *resultFirstCanceledContext) Done() <-chan struct{} {
if c.doneCallCounter.Add(1) == 1 {
close(c.selectWaiting)
return c.selectDone
}
return c.providerDone
}
func (c *resultFirstCanceledContext) Err() error {
return context.DeadlineExceeded
}
func (c *resultFirstCanceledContext) Value(any) any {
return nil
}
type abortedCleanProvider struct {
selectWaiting <-chan struct{}
}
func (p *abortedCleanProvider) Name() string {
return "aborted-clean"
}
func (p *abortedCleanProvider) Scan(ctx context.Context, _ extcs.ScanRequest) (*extcs.Alert, error) {
<-p.selectWaiting
<-ctx.Done()
return nil, nil
}
func (p *abortedCleanProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
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 (m *mockProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return m.Scan(ctx, req)
}
func TestScanForSafety_ModeOff(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
var buf bytes.Buffer
@@ -102,36 +162,131 @@ func TestScanForSafety_NoProvider(t *testing.T) {
}
}
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)
func TestScanForSafety_ScanError_ModeBehavior(t *testing.T) {
for _, tt := range []struct {
name string
mode string
wantBlocked bool
wantWarning bool
}{
{name: "block fails closed", mode: "block", wantBlocked: true, wantWarning: true},
{name: "warn fails open", mode: "warn", wantWarning: true},
{name: "off skips scan", mode: "off"},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", tt.mode)
mp := &mockProvider{name: "mock", err: errors.New("scan broke")}
extcs.Register(mp)
t.Cleanup(func() { 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())
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Blocked != tt.wantBlocked {
t.Fatalf("Blocked = %v, want %v", result.Blocked, tt.wantBlocked)
}
if tt.wantBlocked {
var safetyErr *errs.ContentSafetyError
if !errors.As(result.BlockErr, &safetyErr) {
t.Fatalf("BlockErr = %T, want *errs.ContentSafetyError", result.BlockErr)
}
if !strings.Contains(safetyErr.Message, "scan did not complete") {
t.Fatalf("BlockErr message = %q, want scan-incomplete message", safetyErr.Message)
}
if !errors.Is(result.BlockErr, errScanIncomplete) {
t.Fatal("BlockErr should preserve errScanIncomplete cause")
}
}
if got := strings.Contains(buf.String(), "scan error"); got != tt.wantWarning {
t.Fatalf("scan warning present = %v, want %v; stderr=%q", got, tt.wantWarning, buf.String())
}
})
}
}
func TestScanForSafety_SlowProvider_Timeout_FailOpen(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
func TestScanForSafety_SlowProvider_TimeoutModeBehavior(t *testing.T) {
for _, tt := range []struct {
name string
mode string
wantBlocked bool
}{
{name: "block fails closed", mode: "block", wantBlocked: true},
{name: "warn fails open", mode: "warn"},
} {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", tt.mode)
extcs.Register(&slowProvider{})
t.Cleanup(func() { extcs.Register(nil) })
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")
var buf bytes.Buffer
result := ScanForSafety("lark-cli im +test", map[string]any{}, &buf)
if result.Blocked != tt.wantBlocked {
t.Fatalf("Blocked = %v, want %v", result.Blocked, tt.wantBlocked)
}
if result.Alert != nil {
t.Error("slow provider should return nil alert on timeout")
}
if tt.wantBlocked {
var safetyErr *errs.ContentSafetyError
if !errors.As(result.BlockErr, &safetyErr) {
t.Fatalf("BlockErr = %T, want *errs.ContentSafetyError", result.BlockErr)
}
if !strings.Contains(safetyErr.Message, "did not complete in time") {
t.Fatalf("BlockErr message = %q, want timeout message", safetyErr.Message)
}
}
})
}
if result.Alert != nil {
t.Error("slow provider should return nil alert on timeout")
}
func TestEmitterAbortedCleanLookingScanModeBehavior(t *testing.T) {
tests := []struct {
name string
mode string
wantBlocked bool
}{
{name: "block fails closed", mode: "block", wantBlocked: true},
{name: "warn fails open", mode: "warn"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", tt.mode)
scanCtx := newResultFirstCanceledContext()
extcs.Register(&abortedCleanProvider{selectWaiting: scanCtx.selectWaiting})
t.Cleanup(func() { extcs.Register(nil) })
stdout := &bytes.Buffer{}
emitter := NewEmitter(EmitterConfig{
Out: stdout,
ErrOut: &bytes.Buffer{},
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
})
emitter.scanCtx = func() (context.Context, context.CancelFunc) {
return scanCtx, func() {}
}
err := emitter.Success(map[string]any{"id": "1"}, EmitOptions{Format: FormatJSON})
if tt.wantBlocked {
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", err)
}
if !strings.Contains(safetyErr.Message, "scan did not complete") {
t.Fatalf("Emitter.Success() error = %v, want scan-incomplete message", err)
}
if stdout.Len() != 0 {
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
}
return
}
if err != nil {
t.Fatalf("Emitter.Success() error = %v, want nil", err)
}
if stdout.Len() == 0 {
t.Fatal("Emitter.Success() stdout is empty, want emitted output")
}
})
}
}
@@ -148,6 +303,10 @@ func (s *slowProvider) Scan(ctx context.Context, _ extcs.ScanRequest) (*extcs.Al
}
}
func (s *slowProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return s.Scan(ctx, req)
}
func TestWriteAlertWarning(t *testing.T) {
alert := &extcs.Alert{Provider: "regex", MatchedRules: []string{"r1", "r2"}}
var buf bytes.Buffer

View File

@@ -6,11 +6,11 @@ package output
import (
"bytes"
"encoding/json"
"fmt"
"io"
"maps"
"sort"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
)
// NoticeProvider supplies the notice attached to a structured envelope.
@@ -25,31 +25,30 @@ type PrettyRenderer func(w io.Writer, colorEnabled bool) error
// EmitterConfig contains command-scoped dependencies. A command constructs one
// Emitter and reuses it for its success result or streamed pages.
type EmitterConfig struct {
Out io.Writer
ErrOut io.Writer
CommandPath string
Identity string
ColorEnabled bool
NoticeProvider NoticeProvider
Out io.Writer
ErrOut io.Writer
CommandPath string
Identity string
ColorEnabled bool
NoticeProvider NoticeProvider
MaxBufferedStreamBytes int
}
// EmitOptions describes one result's wire representation.
//
// The format contract is explicit: JSON (including the empty default) uses an
// The format contract is explicit: FormatJSON (the zero value) uses an
// Envelope; pretty, table, csv, and ndjson render naked business data. JQ takes
// precedence over Format and filters the JSON Envelope. Raw affects only JSON
// envelope encoding and jq's complex-value encoding.
//
// JQSafetyWarning preserves the legacy difference between RuntimeContext.emit
// (false) and WriteSuccessEnvelope (true) until their callers are migrated.
// envelope encoding and jq's complex-value encoding. Format is a canonical
// typed value — boundaries reject unknown formats via ParseFormatStrict, so the
// Emitter never sees one and never falls back.
type EmitOptions struct {
Raw bool
Meta *Meta
Format string
JQ string
DryRun bool
Pretty PrettyRenderer
JQSafetyWarning bool
Raw bool
Meta *Meta
Format Format
JQ string
DryRun bool
Pretty PrettyRenderer
}
// StreamOptions describes one streamed page's wire representation. Streaming
@@ -59,7 +58,7 @@ type EmitOptions struct {
// the aggregated result, which the caller's pagination layer owns before it
// streams pages.
type StreamOptions struct {
Format string
Format Format
Pretty PrettyRenderer
}
@@ -72,17 +71,33 @@ type Emitter struct {
identity string
colorEnabled bool
noticeProvider NoticeProvider
scanCtx scanContextFactory
streamFormat string
streamFormat Format
streamFormatSet bool
streamPrettySet bool
streamHasPretty bool
streamFormatter *PaginatedFormatter
streamMode mode
streamModeSet bool
streamBuffer bytes.Buffer
maxStreamBytes int
streamFinished bool
streamFinishErr error
}
const defaultMaxBufferedStreamBytes = 64 << 20
// NewEmitter constructs a command-scoped output emitter.
func NewEmitter(config EmitterConfig) *Emitter {
errOut := config.ErrOut
if errOut == nil {
errOut = io.Discard
}
maxStreamBytes := config.MaxBufferedStreamBytes
if maxStreamBytes <= 0 {
maxStreamBytes = defaultMaxBufferedStreamBytes
}
return &Emitter{
out: config.Out,
errOut: errOut,
@@ -90,6 +105,8 @@ func NewEmitter(config EmitterConfig) *Emitter {
identity: config.Identity,
colorEnabled: config.ColorEnabled,
noticeProvider: config.NoticeProvider,
scanCtx: defaultContentSafetyContext,
maxStreamBytes: maxStreamBytes,
}
}
@@ -97,6 +114,10 @@ func NewEmitter(config EmitterConfig) *Emitter {
// primitives. JSON and jq use the standard envelope; pretty, table, csv, and
// ndjson render the business value directly.
func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
if !opts.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(opts.Format))
}
if err := e.requireOutput(); err != nil {
return err
}
@@ -106,26 +127,49 @@ func (e *Emitter) Success(data interface{}, opts EmitOptions) error {
}
switch opts.Format {
case "", "json":
case FormatJSON:
return e.emitEnvelope(data, true, opts)
case "pretty":
case FormatPretty:
return e.emitPretty(data, opts)
default:
return e.emitFormatted(data, opts.Format)
}
}
// PartialFailure emits a multi-status result whose envelope honestly reports
// ok:false. It is the typed counterpart to Success for batch operations where
// some items failed but the per-item outcomes are the primary stdout output.
// Like the legacy OutPartialFailure it produces only the JSON/jq envelope; the
// caller owns the non-zero exit signal, keeping the Emitter free of exit
// semantics.
func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
// Value scans and emits one naked business value. It is intended for
// long-running streams and custom-format shortcuts whose public contract does
// not use the standard success envelope.
func (e *Emitter) Value(data interface{}, opts StreamOptions) error {
if !opts.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(opts.Format))
}
if err := e.requireOutput(); err != nil {
return err
}
return e.emitEnvelope(data, false, opts)
if opts.Format == FormatPretty && opts.Pretty != nil {
return e.emitPrettyRenderer(data, opts.Pretty)
}
return e.emitValue(data, opts.Format)
}
// PartialFailure emits a multi-status result whose envelope honestly reports
// ok:false. It is the typed counterpart to Success for batch operations where
// some items failed but the per-item outcomes are the primary stdout output.
// JSON and jq retain the failure envelope. Other formats emit the selected
// naked representation while the caller supplies the non-zero exit signal.
func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
if !opts.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(opts.Format))
}
if err := e.requireOutput(); err != nil {
return err
}
if opts.JQ != "" || opts.Format == FormatJSON {
return e.emitEnvelope(data, false, opts)
}
return e.Value(data, StreamOptions{Format: opts.Format, Pretty: opts.Pretty})
}
// StreamPage scans and emits one page while retaining table/csv columns from
@@ -136,54 +180,80 @@ func (e *Emitter) PartialFailure(data interface{}, opts EmitOptions) error {
// jq from the type makes "jq requires aggregated output" a compile-time fact
// instead of a runtime rejection.
func (e *Emitter) StreamPage(data interface{}, opts StreamOptions) error {
if !opts.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(opts.Format))
}
if err := e.requireOutput(); err != nil {
return err
}
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
if e.streamFinished {
return errs.NewInternalError(errs.SubtypeUnknown,
"stream output is already finished")
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
if opts.Format == "pretty" {
if opts.Pretty == nil {
return errs.NewInternalError(errs.SubtypeUnknown,
"pretty output requires a renderer")
}
return e.emit(func(w io.Writer) error {
return opts.Pretty(w, e.colorEnabled)
})
}
format, known := ParseFormat(opts.Format)
if !known && e.streamFormatter == nil && e.errOut != nil {
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", opts.Format)
}
if e.streamFormatter == nil {
if !e.streamFormatSet {
e.streamFormat = opts.Format
e.streamFormatter = NewPaginatedFormatter(nil, format)
e.streamFormatSet = true
} else if opts.Format != e.streamFormat {
return errs.NewInternalError(errs.SubtypeUnknown,
"stream output format changed from %q to %q", e.streamFormat, opts.Format)
}
return e.emit(func(w io.Writer) error {
e.streamFormatter.W = w
return e.streamFormatter.WritePage(data)
})
if opts.Format == FormatPretty {
hasPretty := opts.Pretty != nil
if !e.streamPrettySet {
e.streamHasPretty = hasPretty
e.streamPrettySet = true
} else if hasPretty != e.streamHasPretty {
return errs.NewInternalError(errs.SubtypeUnknown,
"stream pretty renderer availability changed between pages")
}
if opts.Pretty != nil {
var buf bytes.Buffer
if err := opts.Pretty(&buf, e.colorEnabled); err != nil {
return wrapOutputError("render", err)
}
return e.emitStreamBuffer(data, &buf)
}
// Commands without a curated pretty renderer use the generic table
// representation. This keeps --format pretty truthful without requiring
// every shortcut to duplicate a renderer.
opts.Format = FormatTable
}
if e.streamFormatter == nil {
e.streamFormatter = NewPaginatedFormatter(nil, opts.Format)
}
// Render this page, then scan the exact bytes before writing: a rule match
// can form in the rendered page (joined table cells, adjacent objects) even
// when no single value matches.
var buf bytes.Buffer
e.streamFormatter.W = &buf
if err := e.streamFormatter.WritePage(data); err != nil {
return wrapOutputError("render", err)
}
return e.emitStreamBuffer(data, &buf)
}
// FinishStream commits output buffered by StreamPage in block mode. Warn mode
// remains incremental: each page is scanned and written by StreamPage. Callers
// must invoke FinishStream after the final page, including when pagination ends
// with an API error and partial block-mode output should remain visible.
func (e *Emitter) FinishStream() error {
if e.streamFinished {
return e.streamFinishErr
}
e.streamFinished = true
if !e.streamModeSet || e.streamMode != modeBlock || e.streamBuffer.Len() == 0 {
return nil
}
e.streamFinishErr = e.emitScannedBufferMode(&e.streamBuffer, e.streamMode)
return e.streamFinishErr
}
func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
m := modeFromEnv(e.errOut)
env := Envelope{
OK: ok,
Identity: e.identity,
@@ -192,15 +262,14 @@ func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) erro
Meta: opts.Meta,
Notice: e.notice(),
}
if scanResult.Alert != nil {
env.ContentSafetyAlert = scanResult.Alert
}
if opts.JQ != "" {
if scanResult.Alert != nil && opts.JQSafetyWarning {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
sourceScan := e.scanForSafetyMode(data, false, m)
if sourceScan.Blocked {
return sourceScan.BlockErr
}
if sourceScan.Alert != nil {
env.ContentSafetyAlert = sourceScan.Alert
}
// Buffer the jq output manually so jq's own typed error (a validation
// error for a bad expression, an api error for a runtime failure) is
@@ -216,106 +285,220 @@ func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) erro
if jqErr != nil {
return jqErr
}
var renderedScan ScanResult
if !sourceScan.scanFailed {
renderedScan = e.scanRenderedBufferMode(&buf, m)
}
if renderedScan.Blocked {
return renderedScan.BlockErr
}
alert := mergeSafetyAlerts(sourceScan.Alert, renderedScan.Alert)
if alert != nil {
if err := WriteAlertWarning(e.errOut, alert); err != nil {
return wrapOutputError("write", err)
}
}
if _, err := io.Copy(e.out, &buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
return e.emit(func(w io.Writer) error {
if opts.Raw {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(env)
}
return WriteJSON(w, env)
})
}
func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
if opts.Pretty != nil {
return e.emit(func(w io.Writer) error {
return opts.Pretty(w, e.colorEnabled)
})
// Scan both representations. The structured scan detects content changed by
// JSON escaping, while the rendered scan detects matches formed across
// serialized fields.
sourceScan := e.scanForSafetyMode(data, false, m)
if sourceScan.Blocked {
return sourceScan.BlockErr
}
// RuntimeContext.outFormat falls back through Out/OutRaw when no pretty
// renderer is supplied. Keep that second scan visible in the leaf contract
// until production callers are migrated and the legacy behavior is removed.
return e.emitEnvelope(data, true, opts)
}
func (e *Emitter) emitFormatted(data interface{}, rawFormat string) error {
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
format, known := ParseFormat(rawFormat)
if !known && e.errOut != nil {
fmt.Fprintf(e.errOut, "warning: unknown format %q, falling back to json\n", rawFormat)
}
if format == FormatJSON {
return e.printLegacyDataJSON(data)
}
return e.emit(func(w io.Writer) error {
return WriteFormatted(w, data, format)
})
}
type emitterDataMap map[string]interface{}
// printLegacyDataJSON matches FormatValue's JSON branch while sourcing notice
// data from this Emitter instead of PrintJson's global PendingNotice hook.
func (e *Emitter) printLegacyDataJSON(data interface{}) error {
// Normalise structs / named maps to plain generic types first, exactly as
// FormatValue does, so a struct or named-map payload still matches the map
// case below and keeps its injected _notice on the unknown-format fallback.
data = toGeneric(data)
if m, ok := data.(map[string]interface{}); ok {
if _, isEnvelope := m["ok"]; isEnvelope {
if notice := e.notice(); notice != nil {
m = maps.Clone(m)
m["_notice"] = notice
}
}
// The named map retains identical JSON bytes while preventing PrintJson
// from consulting its legacy global notice hook a second time.
return e.emit(func(w io.Writer) error {
return WriteJSON(w, emitterDataMap(m))
})
}
return e.emit(func(w io.Writer) error {
return WriteJSON(w, data)
})
}
func (e *Emitter) emit(render func(io.Writer) error) error {
var buf bytes.Buffer
if err := render(&buf); err != nil {
if err := renderEnvelope(&buf, env, opts.Raw); err != nil {
return wrapOutputError("render", err)
}
var renderedScan ScanResult
if !sourceScan.scanFailed {
renderedScan = e.scanRenderedBufferMode(&buf, m)
}
if renderedScan.Blocked {
return renderedScan.BlockErr
}
if alert := mergeSafetyAlerts(sourceScan.Alert, renderedScan.Alert); alert != nil {
env.ContentSafetyAlert = alert
buf.Reset()
if err := renderEnvelope(&buf, env, opts.Raw); err != nil {
return wrapOutputError("render", err)
}
}
if _, err := io.Copy(e.out, &buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
func (e *Emitter) emitPretty(data interface{}, opts EmitOptions) error {
if opts.Pretty != nil {
return e.emitPrettyRenderer(data, opts.Pretty)
}
return e.emitFormatted(data, FormatPretty)
}
func (e *Emitter) emitPrettyRenderer(data interface{}, renderer PrettyRenderer) error {
// Buffer pretty output so the safety scan sees the exact text that will be
// written to stdout, including anything captured by the opaque renderer.
var buf bytes.Buffer
if err := renderer(&buf, e.colorEnabled); err != nil {
return wrapOutputError("render", err)
}
return e.emitSourceAndRenderedBufferMode(data, &buf, modeFromEnv(e.errOut))
}
// emitFormatted renders naked business data for ndjson, table, csv, and the
// generic pretty representation. Success routes FormatJSON to the envelope and
// curated pretty output to its renderer.
func (e *Emitter) emitFormatted(data interface{}, format Format) error {
var buf bytes.Buffer
if err := WriteFormatted(&buf, data, format); err != nil {
return wrapOutputError("render", err)
}
return e.emitSourceAndRenderedBufferMode(data, &buf, modeFromEnv(e.errOut))
}
func (e *Emitter) emitValue(data interface{}, format Format) error {
var buf bytes.Buffer
var err error
switch format {
case FormatJSON:
err = WriteJSON(&buf, data)
case FormatNDJSON:
err = WriteNDJSON(&buf, data)
case FormatTable:
err = WriteTable(&buf, data)
case FormatCSV:
err = WriteCSV(&buf, data)
case FormatPretty:
err = WriteFormatted(&buf, data, format)
default:
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(format))
}
if err != nil {
return wrapOutputError("render", err)
}
return e.emitSourceAndRenderedBufferMode(data, &buf, modeFromEnv(e.errOut))
}
func (e *Emitter) emitScannedBufferMode(buf *bytes.Buffer, m mode) error {
scanResult := e.scanRenderedBufferMode(buf, m)
return e.emitBufferAfterScan(buf, scanResult)
}
func (e *Emitter) emitSourceAndRenderedBufferMode(data interface{}, buf *bytes.Buffer, m mode) error {
scanResult := e.scanSourceAndRenderedBufferMode(data, buf, m)
return e.emitBufferAfterScan(buf, scanResult)
}
func (e *Emitter) emitBufferAfterScan(buf *bytes.Buffer, scanResult ScanResult) error {
if scanResult.Blocked {
return scanResult.BlockErr
}
if scanResult.Alert != nil {
if err := WriteAlertWarning(e.errOut, scanResult.Alert); err != nil {
return wrapOutputError("write", err)
}
}
if _, err := io.Copy(e.out, buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
func (e *Emitter) emitStreamBuffer(data interface{}, buf *bytes.Buffer) error {
if !e.streamModeSet {
e.streamMode = modeFromEnv(e.errOut)
e.streamModeSet = true
}
switch e.streamMode {
case modeWarn:
return e.emitSourceAndRenderedBufferMode(data, buf, e.streamMode)
case modeBlock:
sourceScan := e.scanForSafetyMode(data, false, e.streamMode)
if sourceScan.Blocked {
return sourceScan.BlockErr
}
if buf.Len() > e.maxStreamBytes-e.streamBuffer.Len() {
return errs.NewContentSafetyError(errs.SubtypeContentSafety,
"content-safety scan input exceeds the %d-byte stream limit; blocked",
e.maxStreamBytes).
WithHint("reduce --page-limit or request fewer records")
}
_, _ = e.streamBuffer.Write(buf.Bytes())
return nil
}
if _, err := io.Copy(e.out, buf); err != nil {
return wrapOutputError("write", err)
}
return nil
}
func (e *Emitter) scanSourceAndRenderedBufferMode(data interface{}, buf *bytes.Buffer, m mode) ScanResult {
sourceScan := e.scanForSafetyMode(data, false, m)
if sourceScan.Blocked || sourceScan.scanFailed {
return sourceScan
}
renderedScan := e.scanRenderedBufferMode(buf, m)
if renderedScan.Blocked {
return renderedScan
}
renderedScan.Alert = mergeSafetyAlerts(sourceScan.Alert, renderedScan.Alert)
return renderedScan
}
func (e *Emitter) scanRenderedBufferMode(buf *bytes.Buffer, m mode) ScanResult {
return e.scanForSafetyMode(buf.String(), true, m)
}
func renderEnvelope(w io.Writer, env Envelope, raw bool) error {
if raw {
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
enc.SetIndent("", " ")
return enc.Encode(env)
}
return WriteJSON(w, env)
}
func mergeSafetyAlerts(first, second *extcs.Alert) *extcs.Alert {
if first == nil {
return second
}
if second == nil {
return first
}
rules := make(map[string]struct{}, len(first.MatchedRules)+len(second.MatchedRules))
for _, rule := range first.MatchedRules {
rules[rule] = struct{}{}
}
for _, rule := range second.MatchedRules {
rules[rule] = struct{}{}
}
mergedRules := make([]string, 0, len(rules))
for rule := range rules {
mergedRules = append(mergedRules, rule)
}
sort.Strings(mergedRules)
provider := first.Provider
if provider == "" {
provider = second.Provider
}
return &extcs.Alert{Provider: provider, MatchedRules: mergedRules}
}
func (e *Emitter) scanForSafetyMode(data interface{}, fullText bool, m mode) ScanResult {
return scanForSafetyMode(e.commandPath, data, e.errOut, fullText, m, e.scanCtx)
}
func wrapOutputError(op string, err error) error {
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s command output", op).WithCause(err)
}

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,10 @@ func (p *emitterSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs
return p.alert, p.err
}
func (p *emitterSafetyProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
const (
runtimeContextLegacyGoldenPath = "testdata/runtime_context_legacy.golden.json"
writeSuccessEnvelopeLegacyGoldenPath = "testdata/write_success_envelope_legacy.golden.json"
@@ -60,6 +64,7 @@ type runtimeContextOracleCase struct {
format string
useFormat bool
pretty bool
keepError bool
notice map[string]interface{}
safetyMode string
safetyAlert *extcs.Alert
@@ -188,15 +193,6 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
useFormat: true,
pretty: true,
},
{
name: "pretty_without_renderer",
data: func() interface{} {
return map[string]interface{}{"name": "Alice"}
},
ok: true,
format: "pretty",
useFormat: true,
},
{
name: "ndjson",
data: func() interface{} {
@@ -236,7 +232,7 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
useFormat: true,
},
{
name: "jq_safety_alert_without_stderr_warning",
name: "jq_safety_alert_writes_stderr_warning",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
@@ -249,7 +245,7 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
},
},
{
name: "scanner_error_fails_open",
name: "scanner_error_warn_mode_fails_open",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
@@ -257,6 +253,16 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
safetyMode: "warn",
safetyErr: errors.New("scanner unavailable"),
},
// Block mode intentionally fails closed when scanning errors.
{
name: "scanner_error_block_mode_fails_closed",
data: func() interface{} {
return map[string]interface{}{"id": "1"}
},
ok: false,
safetyMode: "block",
safetyErr: errors.New("scanner unavailable"),
},
{
name: "scanner_block",
data: func() interface{} {
@@ -269,16 +275,6 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "unknown_format_data_envelope_notice",
data: func() interface{} {
return map[string]interface{}{"ok": true, "value": "fixture"}
},
ok: true,
format: "yaml",
useFormat: true,
notice: map[string]interface{}{"skills": map[string]interface{}{"current": "1.0.0"}},
},
}
golden := loadRuntimeContextLegacyGolden(t)
@@ -309,7 +305,11 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
format: tc.format,
useFormat: tc.useFormat,
pretty: tc.pretty,
keepError: tc.keepError,
}
// tc.format is the string a shortcut's --format flag would carry; the
// boundary parses it to a canonical Format before the Emitter sees it.
format, _ := output.ParseFormat(tc.format)
current := runEmitterWithRuntimeContextContract(tc.data(), output.EmitterConfig{
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
@@ -317,10 +317,10 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
}, tc.ok, output.EmitOptions{
Raw: tc.raw,
Meta: tc.meta,
Format: tc.format,
Format: format,
JQ: tc.jq,
Pretty: emitterPrettyRenderer(tc.pretty),
})
}, tc.keepError)
assertEmitterGolden(t, want, current)
@@ -388,10 +388,15 @@ type runtimeOracleOptions struct {
format string
useFormat bool
pretty bool
keepError bool
}
func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
t.Helper()
if opts.keepError {
return runRuntimeContextShortcutOracle(t, data, opts)
}
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
parent := &cobra.Command{Use: "lark-cli"}
@@ -431,6 +436,42 @@ func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleO
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runRuntimeContextShortcutOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
factory, stdout, stderr, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
})
root := &cobra.Command{Use: "lark-cli", SilenceErrors: true, SilenceUsage: true}
fixture := &cobra.Command{Use: "fixture"}
root.AddCommand(fixture)
shortcut := common.Shortcut{
Service: "fixture",
Command: "+emit",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, runtime *common.RuntimeContext) error {
pretty := func(w io.Writer) {
fmt.Fprintln(w, "pretty:fixture")
}
if !opts.pretty {
pretty = nil
}
if opts.raw {
runtime.OutFormatRaw(data, opts.meta, pretty)
} else {
runtime.OutFormat(data, opts.meta, pretty)
}
return nil
},
}
shortcut.Mount(fixture, factory)
root.SetArgs([]string{"fixture", "+emit", "--as", "bot", "--format", opts.format})
err := root.Execute()
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
@@ -446,7 +487,13 @@ func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, o
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runEmitterWithRuntimeContextContract(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
func runEmitterWithRuntimeContextContract(
data interface{},
config output.EmitterConfig,
ok bool,
opts output.EmitOptions,
keepError bool,
) emitterCapture {
capture := runEmitterSuccess(data, config, ok, opts)
if capture.err != nil {
var safetyErr *errs.ContentSafetyError
@@ -457,6 +504,9 @@ func runEmitterWithRuntimeContextContract(data interface{}, config output.Emitte
capture.stderr += fmt.Sprintf("error: %v\n", capture.err)
return capture
}
if keepError {
return capture
}
capture.err = nil
}
if !ok {
@@ -546,11 +596,10 @@ func TestEmitterMatchesWriteSuccessEnvelopeLegacyOracle(t *testing.T) {
Identity: "bot",
NoticeProvider: func() map[string]interface{} { return notice },
}, true, output.EmitOptions{
Format: "",
Raw: false,
JQ: tc.jq,
DryRun: tc.dryRun,
JQSafetyWarning: true,
Format: output.FormatJSON,
Raw: false,
JQ: tc.jq,
DryRun: tc.dryRun,
})
assertEmitterGolden(t, want, current)
@@ -605,18 +654,9 @@ func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) {
{name: "table", format: output.FormatTable},
{name: "csv", format: output.FormatCSV},
{
name: "warn",
format: output.FormatNDJSON,
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
},
},
{
name: "block",
name: "table warn",
format: output.FormatTable,
safetyMode: "block",
safetyMode: "warn",
safetyAlert: &extcs.Alert{
Provider: "emitter-oracle",
MatchedRules: []string{"fixture-rule"},
@@ -640,7 +680,7 @@ func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) {
t.Cleanup(func() { extcs.Register(nil) })
legacy := runPaginationOracle(pages, tc.format)
current := runEmitterStreamPages(pages, tc.format.String())
current := runEmitterStreamPages(pages, tc.format)
assertEmitterBytes(t, legacy, current)
assertEquivalentError(t, legacy.err, current.err)
@@ -667,7 +707,7 @@ func runPaginationOracle(pages []interface{}, format output.Format) emitterCaptu
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
}
func runEmitterStreamPages(pages []interface{}, format string) emitterCapture {
func runEmitterStreamPages(pages []interface{}, format output.Format) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
@@ -682,6 +722,9 @@ func runEmitterStreamPages(pages []interface{}, format string) emitterCapture {
break
}
}
if emitErr == nil {
emitErr = emitter.FinishStream()
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
}
@@ -706,7 +749,7 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
return map[string]interface{}{"source": "captured"}
},
})
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"}); err != nil {
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: output.FormatJSON}); err != nil {
t.Fatalf("Emitter.Success() error = %v", err)
}
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
@@ -714,7 +757,7 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
}
stdout.Reset()
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "pretty",
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: output.FormatPretty,
Pretty: func(w io.Writer, colorEnabled bool) error {
colorSeen = colorEnabled
_, err := fmt.Fprintln(w, "pretty")
@@ -726,14 +769,6 @@ func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
if !colorSeen {
t.Fatal("PrettyRenderer did not receive captured ColorEnabled value")
}
stdout.Reset()
if err := emitter.Success(map[string]interface{}{"ok": true, "id": "1"}, output.EmitOptions{Format: "yaml"}); err != nil {
t.Fatalf("Emitter.Success(unknown format) error = %v", err)
}
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
t.Fatalf("legacy JSON fallback consulted global notice:\n%s", stdout.String())
}
}
type failingEmitterWriter struct {
@@ -751,7 +786,7 @@ func TestEmitterPropagatesOutputError(t *testing.T) {
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
Raw: true, Format: "json",
Raw: true, Format: output.FormatJSON,
JQ: ".data",
})
if !errors.Is(err, sentinel) {

View File

@@ -41,10 +41,9 @@ func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
Identity: opts.Identity,
NoticeProvider: GetNotice,
}).Success(data, EmitOptions{
Format: "",
Raw: false,
JQ: opts.JqExpr,
DryRun: opts.DryRun,
JQSafetyWarning: true,
Format: FormatJSON,
Raw: false,
JQ: opts.JqExpr,
DryRun: opts.DryRun,
})
}

View File

@@ -9,6 +9,8 @@ import (
"fmt"
"io"
"sort"
"github.com/larksuite/cli/errs"
)
// Known array field names for pagination.
@@ -114,8 +116,22 @@ func FormatValue(w io.Writer, data interface{}, format Format) {
// WriteFormatted formats a single response and returns marshal or write errors.
func WriteFormatted(w io.Writer, data interface{}, format Format) error {
if !format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(format))
}
data = toGeneric(data)
switch format {
case FormatJSON:
return WriteJSON(w, data)
case FormatPretty:
switch data.(type) {
case map[string]interface{}, []interface{}:
return WriteTable(w, data)
default:
_, err := fmt.Fprintln(w, cellStr(data))
return err
}
case FormatNDJSON:
items := ExtractItems(data)
if items != nil {
@@ -137,9 +153,9 @@ func WriteFormatted(w io.Writer, data interface{}, format Format) error {
}
return WriteCSV(w, data)
default: // FormatJSON
return WriteJSON(w, data)
}
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(format))
}
// PaginatedFormatter holds state across paginated calls to ensure
@@ -166,6 +182,10 @@ func (pf *PaginatedFormatter) FormatPage(data interface{}) {
// WritePage formats one page of items and returns marshal or write errors.
func (pf *PaginatedFormatter) WritePage(data interface{}) error {
if !pf.Format.Valid() {
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(pf.Format))
}
switch pf.Format {
case FormatJSON, FormatNDJSON:
if arr, ok := data.([]interface{}); ok {
@@ -194,7 +214,8 @@ func (pf *PaginatedFormatter) WritePage(data interface{}) error {
return writeCSVRows(w, rows, cols, isFirst)
})
}
return nil
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unknown output format %d", int(pf.Format))
}
// formatStructuredPage handles column-locking logic shared by table and csv.

View File

@@ -6,8 +6,11 @@ package output
import (
"bytes"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestFormatValue_JSON(t *testing.T) {
@@ -98,6 +101,18 @@ func TestFormatValue_CSV(t *testing.T) {
}
}
func TestWriteFormatted_InvalidFormatReturnsInternalErrorWithoutOutput(t *testing.T) {
var buf bytes.Buffer
err := WriteFormatted(&buf, map[string]interface{}{"id": "1"}, Format(99))
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeUnknown {
t.Fatalf("WriteFormatted() problem = %#v, %v; want internal/unknown", problem, ok)
}
if buf.Len() != 0 {
t.Fatalf("WriteFormatted() wrote %d bytes, want 0", buf.Len())
}
}
func TestPaginatedFormatter_JSON(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, FormatJSON)
@@ -170,6 +185,22 @@ func TestPaginatedFormatter_CSV(t *testing.T) {
}
}
func TestPaginatedFormatterWritePage_InvalidFormatReturnsInternalErrorWithoutOutput(t *testing.T) {
var buf bytes.Buffer
pf := NewPaginatedFormatter(&buf, Format(99))
err := pf.WritePage([]interface{}{map[string]interface{}{"id": "1"}})
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("WritePage() error = %T, want *errs.InternalError", err)
}
if internalErr.Category != errs.CategoryInternal || internalErr.Subtype != errs.SubtypeUnknown {
t.Fatalf("WritePage() problem = %s/%s, want internal/unknown", internalErr.Category, internalErr.Subtype)
}
if buf.Len() != 0 {
t.Fatalf("WritePage() wrote %d bytes, want 0", buf.Len())
}
}
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

View File

@@ -3,7 +3,12 @@
package output
import "strings"
import (
"fmt"
"strings"
"github.com/larksuite/cli/errs"
)
// Format represents an output format type.
type Format int
@@ -13,11 +18,22 @@ const (
FormatNDJSON
FormatTable
FormatCSV
FormatPretty
)
// Valid reports whether f is one of the defined output formats.
func (f Format) Valid() bool {
return f >= FormatJSON && f <= FormatPretty
}
// 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.
//
// Prefer ParseFormatStrict at flag boundaries so an unknown --format fails
// loudly instead of degrading to JSON. ParseFormat's lenient fallback is kept
// for internal callers that only need a best-effort classification (e.g.
// ValidateJqFlags, which folds any non-JSON — known or not — into one branch).
func ParseFormat(s string) (Format, bool) {
switch strings.ToLower(s) {
case "json", "":
@@ -28,21 +44,41 @@ func ParseFormat(s string) (Format, bool) {
return FormatTable, true
case "csv":
return FormatCSV, true
case "pretty":
return FormatPretty, true
default:
return FormatJSON, false
}
}
// ParseFormatStrict parses a --format value into a typed Format, returning a
// typed ValidationError for any unrecognized value instead of silently falling
// back to JSON. Flag boundaries use this so an unknown format is a typed
// failure the caller cannot accidentally serve as JSON, and so the Emitter
// downstream only ever receives a canonical Format.
func ParseFormatStrict(s string) (Format, error) {
if f, ok := ParseFormat(s); ok {
return f, nil
}
return FormatJSON, errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown output format %q (want json, ndjson, table, csv, or pretty)", s).
WithParam("--format")
}
// String returns the string representation of a Format.
func (f Format) String() string {
switch f {
case FormatJSON:
return "json"
case FormatNDJSON:
return "ndjson"
case FormatTable:
return "table"
case FormatCSV:
return "csv"
case FormatPretty:
return "pretty"
default:
return "json"
return fmt.Sprintf("unknown(%d)", int(f))
}
}

View File

@@ -3,7 +3,11 @@
package output
import "testing"
import (
"testing"
"github.com/larksuite/cli/errs"
)
func TestParseFormat(t *testing.T) {
tests := []struct {
@@ -23,6 +27,9 @@ func TestParseFormat(t *testing.T) {
{"csv", FormatCSV, true},
{"CSV", FormatCSV, true},
{"Csv", FormatCSV, true},
{"pretty", FormatPretty, true},
{"PRETTY", FormatPretty, true},
{"Pretty", FormatPretty, true},
{"", FormatJSON, true},
// Legacy/unknown values fall back to JSON with ok=false
{"data", FormatJSON, false},
@@ -55,7 +62,8 @@ func TestFormatString(t *testing.T) {
{FormatNDJSON, "ndjson"},
{FormatTable, "table"},
{FormatCSV, "csv"},
{Format(99), "json"}, // unknown falls back
{FormatPretty, "pretty"},
{Format(99), "unknown(99)"},
}
for _, tt := range tests {
@@ -67,3 +75,59 @@ func TestFormatString(t *testing.T) {
})
}
}
func TestFormatValid(t *testing.T) {
for _, format := range []Format{FormatJSON, FormatNDJSON, FormatTable, FormatCSV, FormatPretty} {
if !format.Valid() {
t.Errorf("Format(%d).Valid() = false, want true", format)
}
}
if Format(99).Valid() {
t.Error("Format(99).Valid() = true, want false")
}
}
func TestParseFormatStrict(t *testing.T) {
valid := []struct {
input string
want Format
}{
{"", FormatJSON},
{"json", FormatJSON},
{"JSON", FormatJSON},
{"ndjson", FormatNDJSON},
{"table", FormatTable},
{"csv", FormatCSV},
{"pretty", FormatPretty},
{"Pretty", FormatPretty},
}
for _, tt := range valid {
t.Run("valid/"+tt.input, func(t *testing.T) {
got, err := ParseFormatStrict(tt.input)
if err != nil {
t.Fatalf("ParseFormatStrict(%q) error = %v, want nil", tt.input, err)
}
if got != tt.want {
t.Errorf("ParseFormatStrict(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
// Unknown values are a typed validation error on --format, never a silent
// fallback to JSON.
for _, input := range []string{"yaml", "xml", "data", "raw", "tabel"} {
t.Run("unknown/"+input, func(t *testing.T) {
got, err := ParseFormatStrict(input)
if err == nil {
t.Fatalf("ParseFormatStrict(%q) error = nil, want validation error", input)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryValidation {
t.Fatalf("ParseFormatStrict(%q) problem = %#v, %v; want validation category", input, problem, ok)
}
if got != FormatJSON {
t.Errorf("ParseFormatStrict(%q) format = %v, want FormatJSON sentinel", input, got)
}
})
}
}

View File

@@ -70,7 +70,14 @@ func ValidateJqFlags(jqExpr, outputFlag, format string) error {
if outputFlag != "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--jq and --output are mutually exclusive")
}
if format != "" && format != "json" {
// Classify via ParseFormat so the JSON check is case-insensitive and shares
// the single canonical format definition. Only a recognized JSON format is
// compatible with --jq; every other value conflicts and is rejected: known
// non-JSON framework formats ("csv", "pretty", ...) and values ParseFormat
// does not recognize as JSON (a shortcut's own "markdown"/"data" enum, or an
// unknown format that ParseFormatStrict rejects downstream). The !ok guard
// keeps those unrecognized values out of the JSON-compatible branch.
if f, ok := ParseFormat(format); !ok || f != FormatJSON {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--jq and --format %s are mutually exclusive", format)
}
return ValidateJqExpression(jqExpr)

View File

@@ -160,8 +160,13 @@ func TestValidateJqFlags(t *testing.T) {
{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: ""},
// Format classification is case-insensitive via ParseFormat: an
// upper/mixed-case JSON must not be mistaken for a conflicting format.
{name: "jq with uppercase JSON format", jqExpr: ".data", outputFlag: "", format: "JSON", wantErr: ""},
{name: "jq with mixed-case 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 pretty conflict", jqExpr: ".data", outputFlag: "", format: "pretty", wantErr: "--jq and --format pretty 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"},
}

View File

@@ -81,21 +81,6 @@ func injectNotice(data interface{}) {
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 {

View File

@@ -5,7 +5,7 @@
"stderr": ""
},
"format_raw_json_preserves_html": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"<p>a&b</p>\"\n }\n}\n",
"stderr": ""
},
"jq_invalid_expression": {
@@ -22,9 +22,9 @@
"exit_code": 2
}
},
"jq_safety_alert_without_stderr_warning": {
"jq_safety_alert_writes_stderr_warning": {
"stdout": "1\n",
"stderr": ""
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
},
"jq_scalar": {
"stdout": "Alice\n",
@@ -62,16 +62,12 @@
"stdout": "pretty:fixture\n",
"stderr": ""
},
"pretty_without_renderer": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"name\": \"Alice\"\n }\n}\n",
"stderr": ""
},
"raw_jq_complex": {
"stdout": "{\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n}\n",
"stdout": "{\n \"html\": \"<p>a&b</p>\"\n}\n",
"stderr": ""
},
"raw_json_preserves_html": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"\u003cp\u003ea\u0026b\u003c/p\u003e\"\n }\n}\n",
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"html\": \"<p>a&b</p>\"\n }\n}\n",
"stderr": ""
},
"scanner_block": {
@@ -91,17 +87,27 @@
"exit_code": 6
}
},
"scanner_error_fails_open": {
"scanner_error_block_mode_fails_closed": {
"stdout": "",
"stderr": "warning: content safety scan error: scanner unavailable\n",
"error": {
"go_type": "*errs.ContentSafetyError",
"json": {
"type": "policy",
"subtype": "content_safety",
"message": "content-safety scan did not complete; blocked (block mode)"
},
"message": "content-safety scan did not complete; blocked (block mode)",
"exit_code": 6
}
},
"scanner_error_warn_mode_fails_open": {
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
"stderr": "warning: content safety scan error: scanner unavailable\n"
},
"table_with_safety_warning": {
"stdout": "id name \n── ─────\n1 Alice\n",
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
},
"unknown_format_data_envelope_notice": {
"stdout": "{\n \"_notice\": {\n \"skills\": {\n \"current\": \"1.0.0\"\n }\n },\n \"ok\": true,\n \"value\": \"fixture\"\n}\n",
"stderr": "warning: unknown format \"yaml\", falling back to json\n"
}
}
}

View File

@@ -24,6 +24,15 @@ type regexProvider struct {
func (p *regexProvider) Name() string { return "regex" }
func (p *regexProvider) Scan(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.scan(ctx, req, false)
}
func (p *regexProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
req.FullText = true
return p.scan(ctx, req, true)
}
func (p *regexProvider) scan(ctx context.Context, req extcs.ScanRequest, fullText bool) (*extcs.Alert, error) {
cfg, err := p.loadOrCreate(req.ErrOut)
if err != nil {
return nil, err
@@ -37,9 +46,11 @@ func (p *regexProvider) Scan(ctx context.Context, req extcs.ScanRequest) (*extcs
}
data := normalize(req.Data)
s := &scanner{rules: cfg.Rules}
s := &scanner{rules: cfg.Rules, fullText: fullText}
hits := make(map[string]struct{})
s.walk(ctx, data, hits, 0)
if err := s.walk(ctx, data, hits, 0); err != nil {
return nil, err
}
if len(hits) == 0 {
return nil, nil

View File

@@ -4,15 +4,22 @@
package contentsafety
import (
"bytes"
"context"
"errors"
"io"
"os"
"path/filepath"
"strings"
"testing"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/output"
)
var _ extcs.FullTextProvider = (*regexProvider)(nil)
func writeTestConfig(t *testing.T, content string) string {
t.Helper()
dir := t.TempDir()
@@ -70,6 +77,28 @@ func TestProvider_ScanCleanData(t *testing.T) {
}
}
func TestProvider_ScanCanceledContextReturnsError(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [{"id": "r1", "pattern": "(?i)inject"}]
}`)
p := &regexProvider{configDir: dir}
ctx, cancel := context.WithCancel(context.Background())
cancel()
alert, err := p.Scan(ctx, extcs.ScanRequest{
Path: "im.messages_search",
Data: map[string]any{"text": "Hello, clean data"},
ErrOut: io.Discard,
})
if alert != nil {
t.Fatalf("Scan() alert = %v, want nil", alert)
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("Scan() error = %v, want context.Canceled", err)
}
}
func TestProvider_ScanNotInAllowlist(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["im"],
@@ -141,6 +170,169 @@ func TestProvider_ScanNestedData(t *testing.T) {
}
}
func TestProvider_FullTextBypassesPerStringCap(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [{"id": "tail", "pattern": "TAIL_MARKER"}]
}`)
p := &regexProvider{configDir: dir}
text := strings.Repeat("x", maxStringBytes+1) + "TAIL_MARKER"
alert, err := p.Scan(context.Background(), extcs.ScanRequest{
Path: "test",
Data: text,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("Scan() structured-data error = %v", err)
}
if alert != nil {
t.Fatalf("structured-data scan should retain the per-string cap, got %v", alert)
}
alert, err = p.ScanFullText(context.Background(), extcs.ScanRequest{
Path: "test",
Data: text,
ErrOut: io.Discard,
})
if err != nil {
t.Fatalf("ScanFullText() error = %v", err)
}
if alert == nil || len(alert.MatchedRules) != 1 || alert.MatchedRules[0] != "tail" {
t.Fatalf("full-text scan alert = %v, want tail match", alert)
}
}
func TestEmitterStructuredBlockFullTextWritesZeroBytesAndWarnEmits(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [
{"id": "prefix", "pattern": "PREFIX_MARKER"},
{"id": "tail", "pattern": "TAIL_MARKER"}
]
}`)
p := &regexProvider{configDir: dir}
extcs.Register(p)
t.Cleanup(func() { extcs.Register(nil) })
data := map[string]any{
"text": "PREFIX_MARKER" + strings.Repeat("x", maxStringBytes+1) + "TAIL_MARKER",
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
blockStdout := &bytes.Buffer{}
blockEmitter := output.NewEmitter(output.EmitterConfig{
Out: blockStdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := blockEmitter.Success(data, output.EmitOptions{Format: output.FormatJSON})
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("block Emitter.Success() error = %T, want *errs.ContentSafetyError", err)
}
foundTail := false
for _, ruleID := range safetyErr.Rules {
if ruleID == "tail" {
foundTail = true
break
}
}
if !foundTail {
t.Fatalf("block matched rules = %v, want tail match beyond per-string cap", safetyErr.Rules)
}
if blockStdout.Len() != 0 {
t.Fatalf("block stdout bytes = %d, want 0", blockStdout.Len())
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
warnStdout := &bytes.Buffer{}
warnStderr := &bytes.Buffer{}
warnEmitter := output.NewEmitter(output.EmitterConfig{
Out: warnStdout,
ErrOut: warnStderr,
CommandPath: "lark-cli fixture +emit",
})
if err := warnEmitter.Success(data, output.EmitOptions{Format: output.FormatJSON}); err != nil {
t.Fatalf("warn Emitter.Success() error = %v", err)
}
if warnStdout.Len() == 0 {
t.Fatal("warn stdout bytes = 0, want emitted structured payload")
}
if !strings.Contains(warnStdout.String(), `"_content_safety_alert"`) ||
!strings.Contains(warnStdout.String(), `"prefix"`) {
t.Fatalf("warn stdout = %q, want embedded prefix content-safety warning", warnStdout.String())
}
if warnStderr.Len() != 0 {
t.Fatalf("warn stderr = %q, want empty for JSON envelope warning", warnStderr.String())
}
}
func TestEmitterStructuredBlockDepthIncompleteWritesZeroBytes(t *testing.T) {
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [{"id": "deep", "pattern": "DEEP_MARKER"}]
}`)
p := &regexProvider{configDir: dir}
extcs.Register(p)
t.Cleanup(func() { extcs.Register(nil) })
var data any = "DEEP_MARKER"
for i := 0; i < maxDepth+5; i++ {
data = map[string]any{"nested": data}
}
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
stdout := &bytes.Buffer{}
emitter := output.NewEmitter(output.EmitterConfig{
Out: stdout,
ErrOut: io.Discard,
CommandPath: "lark-cli fixture +emit",
})
err := emitter.Success(data, output.EmitOptions{Format: output.FormatJSON})
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", err)
}
if !strings.Contains(safetyErr.Message, "scan did not complete") {
t.Fatalf("Emitter.Success() error = %v, want scan-incomplete message", err)
}
if stdout.Len() != 0 {
t.Fatalf("block stdout bytes = %d, want 0", stdout.Len())
}
}
func TestProvider_ScanDetectsInjectionInMapKey(t *testing.T) {
// A rule match hiding in a map key (which JSON/NDJSON/table/CSV all emit)
// must be detected, not just matches in values.
dir := writeTestConfig(t, `{
"allowlist": ["all"],
"rules": [{"id": "override", "pattern": "(?i)ignore previous instructions"}]
}`)
p := &regexProvider{configDir: dir}
data := map[string]any{"ignore previous instructions": "ok"}
for _, tc := range []struct {
name string
scan func() (*extcs.Alert, error)
}{
{"Scan", func() (*extcs.Alert, error) {
return p.Scan(context.Background(), extcs.ScanRequest{Path: "test", Data: data, ErrOut: io.Discard})
}},
{"ScanFullText", func() (*extcs.Alert, error) {
return p.ScanFullText(context.Background(), extcs.ScanRequest{Path: "test", Data: data, ErrOut: io.Discard})
}},
} {
t.Run(tc.name, func(t *testing.T) {
alert, err := tc.scan()
if err != nil {
t.Fatalf("%s() error = %v", tc.name, err)
}
if alert == nil || len(alert.MatchedRules) != 1 || alert.MatchedRules[0] != "override" {
t.Fatalf("%s() alert = %v, want override match on the map key", tc.name, alert)
}
})
}
}
func TestProvider_EmptyRulesNoAlert(t *testing.T) {
dir := writeTestConfig(t, `{"allowlist":["all"],"rules":[]}`)
p := &regexProvider{configDir: dir}

View File

@@ -5,6 +5,8 @@ package contentsafety
import (
"context"
"errors"
"fmt"
"regexp"
)
@@ -13,38 +15,52 @@ const (
maxDepth = 64
)
var errScanIncomplete = errors.New("content safety scan incomplete")
type rule struct {
ID string
Pattern *regexp.Regexp
}
type scanner struct {
rules []rule
rules []rule
fullText bool
}
func (s *scanner) walk(ctx context.Context, v any, hits map[string]struct{}, depth int) {
if depth > maxDepth {
return
func (s *scanner) walk(ctx context.Context, v any, hits map[string]struct{}, depth int) error {
if err := ctx.Err(); err != nil {
return err
}
if ctx.Err() != nil {
return
if depth > maxDepth {
if s.fullText {
return fmt.Errorf("%w: maximum depth %d exceeded", errScanIncomplete, maxDepth)
}
return nil
}
switch t := v.(type) {
case string:
s.scanString(t, hits)
case map[string]any:
for _, child := range t {
s.walk(ctx, child, hits, depth+1)
for k, child := range t {
// Scan the key too: JSON/NDJSON/table/CSV all emit map keys, so a
// rule match hiding in a key must not slip past block mode.
s.scanString(k, hits)
if err := s.walk(ctx, child, hits, depth+1); err != nil {
return err
}
}
case []any:
for _, child := range t {
s.walk(ctx, child, hits, depth+1)
if err := s.walk(ctx, child, hits, depth+1); err != nil {
return err
}
}
}
return ctx.Err()
}
func (s *scanner) scanString(text string, hits map[string]struct{}) {
if len(text) > maxStringBytes {
if !s.fullText && len(text) > maxStringBytes {
text = text[:maxStringBytes]
}
for _, r := range s.rules {

View File

@@ -5,6 +5,7 @@ package contentsafety
import (
"context"
"errors"
"regexp"
"testing"
)
@@ -45,6 +46,23 @@ func TestScanString_Truncate(t *testing.T) {
}
}
func TestScanString_FullTextDoesNotTruncate(t *testing.T) {
s := &scanner{
rules: []rule{testRule("tail", `TAIL_MARKER`)},
fullText: true,
}
big := make([]byte, maxStringBytes+100)
for i := range big {
big[i] = 'x'
}
copy(big[maxStringBytes+10:], "TAIL_MARKER")
hits := make(map[string]struct{})
s.scanString(string(big), hits)
if _, ok := hits["tail"]; !ok {
t.Error("full-text scan should match marker beyond maxStringBytes")
}
}
func TestScanString_SkipsDuplicate(t *testing.T) {
s := &scanner{rules: []rule{testRule("r1", `match`)}}
hits := map[string]struct{}{"r1": {}}
@@ -62,16 +80,34 @@ func TestWalk_NestedMap(t *testing.T) {
},
}
hits := make(map[string]struct{})
s.walk(context.Background(), data, hits, 0)
if err := s.walk(context.Background(), data, hits, 0); err != nil {
t.Fatalf("walk() error = %v", err)
}
if _, ok := hits["found"]; !ok {
t.Error("expected to find 'inject' in nested map")
}
}
func TestWalk_ScansMapKeys(t *testing.T) {
// JSON/NDJSON/table/CSV all emit map keys, so a rule match hiding in a key
// must be scanned too — not only the value.
s := &scanner{rules: []rule{testRule("found", `(?i)inject`)}}
data := map[string]any{"please inject this": "harmless value"}
hits := make(map[string]struct{})
if err := s.walk(context.Background(), data, hits, 0); err != nil {
t.Fatalf("walk() error = %v", err)
}
if _, ok := hits["found"]; !ok {
t.Error("expected to match a rule hiding in a map key")
}
}
func TestWalk_Array(t *testing.T) {
s := &scanner{rules: []rule{testRule("found", `(?i)inject`)}}
hits := make(map[string]struct{})
s.walk(context.Background(), []any{"normal", "try to inject"}, hits, 0)
if err := s.walk(context.Background(), []any{"normal", "try to inject"}, hits, 0); err != nil {
t.Fatalf("walk() error = %v", err)
}
if _, ok := hits["found"]; !ok {
t.Error("expected to find 'inject' in array")
}
@@ -84,18 +120,42 @@ func TestWalk_MaxDepth(t *testing.T) {
data = map[string]any{"n": data}
}
hits := make(map[string]struct{})
s.walk(context.Background(), data, hits, 0)
if err := s.walk(context.Background(), data, hits, 0); err != nil {
t.Fatalf("walk() error = %v", err)
}
if _, ok := hits["deep"]; ok {
t.Error("should not reach string beyond maxDepth")
}
}
func TestWalk_FullTextMaxDepthReturnsIncomplete(t *testing.T) {
s := &scanner{
rules: []rule{testRule("deep", `secret`)},
fullText: true,
}
var data any = "secret"
for i := 0; i < maxDepth+5; i++ {
data = map[string]any{"n": data}
}
hits := make(map[string]struct{})
err := s.walk(context.Background(), data, hits, 0)
if !errors.Is(err, errScanIncomplete) {
t.Fatalf("walk() error = %v, want errScanIncomplete", err)
}
if _, ok := hits["deep"]; ok {
t.Error("full-text walk should report incomplete before matching data beyond maxDepth")
}
}
func TestWalk_ContextCancel(t *testing.T) {
s := &scanner{rules: []rule{testRule("found", `target`)}}
ctx, cancel := context.WithCancel(context.Background())
cancel()
hits := make(map[string]struct{})
s.walk(ctx, map[string]any{"key": "target"}, hits, 0)
err := s.walk(ctx, map[string]any{"key": "target"}, hits, 0)
if !errors.Is(err, context.Canceled) {
t.Fatalf("walk() error = %v, want context.Canceled", err)
}
if _, ok := hits["found"]; ok {
t.Error("should not match after context cancel")
}

View File

@@ -29,7 +29,7 @@ func SafeLocalFlagPath(flagName, value string) (string, error) {
return value, nil
}
if _, err := SafeInputPath(value); err != nil {
return "", fmt.Errorf("%s: %v", flagName, err)
return "", fmt.Errorf("%s: %w", flagName, err)
}
return value, nil
}

View File

@@ -6,10 +6,9 @@ package base
import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -31,7 +30,7 @@ func outputRecordMarkdown(runtime *common.RuntimeContext, data map[string]interf
func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[string]interface{}, renderer func(map[string]interface{}) (string, error)) error {
if runtime.JqExpr != "" {
if !runtime.Changed("format") {
runtime.Out(data, nil)
runtime.OutJSON(data, nil)
return nil
}
return baseValidationErrorf("--jq and --format markdown are mutually exclusive")
@@ -39,32 +38,13 @@ func outputRecordMarkdownWithRenderer(runtime *common.RuntimeContext, data map[s
rendered, err := renderer(data)
if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "warning: record markdown render failed, falling back to json: %v\n", err)
runtime.Out(data, nil)
runtime.OutJSON(data, nil)
return nil
}
scanResult := output.ScanForSafety(runtime.Cmd.CommandPath(), data, runtime.IO().ErrOut)
if scanResult.Blocked {
return baseContentSafetyBlockError(scanResult)
}
if scanResult.Alert != nil {
output.WriteAlertWarning(runtime.IO().ErrOut, scanResult.Alert)
}
fmt.Fprint(runtime.IO().Out, rendered)
return nil
}
func baseContentSafetyBlockError(scanResult output.ScanResult) error {
message := "content safety violation detected"
var rules []string
if scanResult.Alert != nil {
rules = scanResult.Alert.MatchedRules
}
if len(rules) > 0 {
message = fmt.Sprintf("content safety violation detected (rules: %s)", strings.Join(rules, ", "))
}
return errs.NewContentSafetyError(errs.SubtypeUnknown, "%s", message).
WithRules(rules...).
WithCause(scanResult.BlockErr)
return runtime.EmitRenderedValue(data, func(w io.Writer) error {
_, writeErr := io.WriteString(w, rendered)
return writeErr
})
}
func outputRecordGetMarkdown(runtime *common.RuntimeContext, data map[string]interface{}) error {

View File

@@ -31,6 +31,10 @@ func (p *recordMarkdownCSTestProvider) Scan(_ context.Context, _ extcs.ScanReque
return p.alert, nil
}
func (p *recordMarkdownCSTestProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeContext {
parentCmd := &cobra.Command{Use: "lark-cli"}
baseCmd := &cobra.Command{Use: "base"}
@@ -41,6 +45,7 @@ func newRecordMarkdownTestRuntime(stdout, stderr *bytes.Buffer) *common.RuntimeC
return &common.RuntimeContext{
Config: &core.CliConfig{Brand: core.BrandFeishu},
Cmd: cmd,
Format: "markdown",
Factory: &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}},
}
}

View File

@@ -324,7 +324,7 @@ var CalendarSearchEvent = common.Shortcut{
})
if hasMore && runtime.Format != "json" && runtime.Format != "" {
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
fmt.Fprintf(runtime.IO().ErrOut, "\n(more available, page_token: %s)\n", pageToken)
}
return nil
},

View File

@@ -1352,7 +1352,7 @@ func TestAgenda_Success(t *testing.T) {
"+agenda",
"--start", "2025-03-21",
"--end", "2025-03-21",
"--format", "prettry",
"--format", "pretty",
"--as", "bot",
}, f, stdout)

View File

@@ -698,41 +698,78 @@ func wrapLegacyPrettyRenderer(prettyFn func(w io.Writer)) output.PrettyRenderer
}
}
// Out prints a success JSON envelope to stdout.
func (ctx *RuntimeContext) emitOutput(data interface{}, meta *output.Meta, raw bool, prettyFn func(w io.Writer), formatName string) {
format, ok := output.ParseFormat(formatName)
if !ok {
ctx.handleEmitterError(errs.NewInternalError(errs.SubtypeUnknown,
"output helper received unsupported format %q", formatName))
return
}
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
Format: format,
Raw: raw,
JQ: ctx.JqExpr,
Meta: meta,
Pretty: wrapLegacyPrettyRenderer(prettyFn),
}))
}
// EmitValue writes a naked business value with the selected formatter. Custom
// output contracts and long-running callbacks use this method when a success
// envelope would change their wire representation.
func (ctx *RuntimeContext) EmitValue(data interface{}, formatName string) error {
format, err := output.ParseFormatStrict(formatName)
if err != nil {
return err
}
return ctx.newEmitter().Value(data, output.StreamOptions{Format: format})
}
// EmitRenderedValue writes a caller-rendered naked value after scanning both
// the structured source and the exact rendered bytes.
func (ctx *RuntimeContext) EmitRenderedValue(data interface{}, renderer func(io.Writer) error) error {
return ctx.newEmitter().Value(data, output.StreamOptions{
Format: output.FormatPretty,
Pretty: func(w io.Writer, _ bool) error {
return renderer(w)
},
})
}
// Out prints a success result using the selected output format.
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
Format: "",
Raw: false,
JQ: ctx.JqExpr,
Meta: meta,
}))
ctx.emitOutput(data, meta, false, nil, ctx.Format)
}
// OutRaw prints a success JSON envelope to stdout with HTML escaping disabled.
// Use this instead of Out when the data contains XML/HTML content (e.g. document bodies)
// that should be preserved as-is in JSON output.
// OutRaw prints a success result using the selected output format. JSON
// envelope output preserves XML/HTML content without escaping.
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
Format: "",
Raw: true,
JQ: ctx.JqExpr,
Meta: meta,
}))
ctx.emitOutput(data, meta, true, nil, ctx.Format)
}
// OutPartialFailure writes an ok:false multi-status result envelope to stdout
// and returns the partial-failure exit signal. Use it for batch operations
// where some items failed but the per-item outcomes are the primary output:
// the full result (summary + per-item statuses) stays machine-readable on
// stdout, the process exits non-zero, and nothing is written to stderr.
// OutJSON prints a success JSON envelope regardless of a shortcut's custom
// format flag. It is reserved for branches whose output contract is JSON.
func (ctx *RuntimeContext) OutJSON(data interface{}, meta *output.Meta) {
ctx.emitOutput(data, meta, false, nil, output.FormatJSON.String())
}
// OutPartialFailure writes a multi-status result in the selected format and
// returns the partial-failure exit signal. JSON and jq retain an ok:false
// envelope; table, csv, ndjson, and pretty emit the full result as naked data.
// The process exits non-zero and stdout remains parseable in the requested
// format.
//
// It is the typed alternative to `Out(...)` + `output.ErrBare(...)` — the
// envelope's ok field honestly reports failure instead of a misleading
// ok:true, and the exit signal is distinct from ErrBare (the
// stdout-carries-the-answer silent-exit signal).
// JSON's ok field honestly reports failure, and the exit signal is distinct
// from ErrBare (the stdout-carries-the-answer silent-exit signal).
func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta) error {
format, err := output.ParseFormatStrict(ctx.Format)
if err != nil {
ctx.handleEmitterError(err)
return err
}
ctx.handleEmitterError(ctx.newEmitter().PartialFailure(data, output.EmitOptions{
Format: "",
Format: format,
Raw: false,
JQ: ctx.JqExpr,
Meta: meta,
@@ -748,25 +785,13 @@ func (ctx *RuntimeContext) OutPartialFailure(data interface{}, meta *output.Meta
// When JqExpr is set, envelope filtering takes precedence over format.
// The Emitter handles content safety scanning for every format.
func (ctx *RuntimeContext) OutFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
Format: ctx.Format,
Raw: false,
JQ: ctx.JqExpr,
Meta: meta,
Pretty: wrapLegacyPrettyRenderer(prettyFn),
}))
ctx.emitOutput(data, meta, false, prettyFn, ctx.Format)
}
// OutFormatRaw is like OutFormat but with HTML escaping disabled in JSON output.
// Use this when the data contains XML/HTML content that should be preserved as-is.
func (ctx *RuntimeContext) OutFormatRaw(data interface{}, meta *output.Meta, prettyFn func(w io.Writer)) {
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
Format: ctx.Format,
Raw: true,
JQ: ctx.JqExpr,
Meta: meta,
Pretty: wrapLegacyPrettyRenderer(prettyFn),
}))
ctx.emitOutput(data, meta, true, prettyFn, ctx.Format)
}
// ── Scope pre-check ──
@@ -876,18 +901,25 @@ func (s Shortcut) mountDeclarative(ctx context.Context, parent *cobra.Command, f
// runShortcut is the execution pipeline for a declarative shortcut.
// Each step is a clear phase: identity → config → scopes → context → validate → execute.
func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bool) error {
// --print-schema short-circuits everything below: it's pure local
// introspection, no identity / scope / network needed. The flag is
// only registered when the shortcut opts in via PrintFlagSchema.
if s.PrintFlagSchema != nil {
if want, _ := cmd.Flags().GetBool("print-schema"); want {
formatName, _ := cmd.Flags().GetString("format")
if !shortcutDeclaresFormatFlag(s) {
canonicalFormat, err := canonicalizeFrameworkFormatFlag(cmd)
if err != nil {
return err
}
formatName = canonicalFormat
}
if !strings.EqualFold(strings.TrimSpace(formatName), output.FormatJSON.String()) {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--print-schema requires --format json").
WithParam("--format").
WithHint("rerun with --format json")
}
flagName, _ := cmd.Flags().GetString("flag-name")
out, err := s.PrintFlagSchema(strings.TrimSpace(flagName))
if err != nil {
// PrintFlagSchema implementations return bare errors; wrap as a
// typed validation error so --print-schema (an agent-facing
// introspection path) yields a parseable envelope, not a plain
// string.
if !errs.IsTyped(err) {
err = errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err.Error()).WithCause(err)
}
@@ -928,6 +960,13 @@ func runShortcut(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, botOnly bo
if err := resolveInputFlags(rctx, s.Flags); err != nil {
return err
}
if !shortcutDeclaresFormatFlag(s) {
canonicalFormat, err := canonicalizeFrameworkFormatFlag(rctx.Cmd)
if err != nil {
return err
}
rctx.Format = canonicalFormat
}
if err := output.ValidateJqFlags(rctx.JqExpr, "", rctx.Format); err != nil {
return err
}
@@ -1131,8 +1170,13 @@ func handleShortcutDryRun(f *cmdutil.Factory, rctx *RuntimeContext, s *Shortcut)
// Same data.context contract as the service/api dry-run paths.
dryResult.Context(rctx.Config.AppID, rctx.UserOpenId())
}
dryRunFormat := rctx.Format
if !shortcutDeclaresFormatFlag(s) {
format, _ := output.ParseFormat(rctx.Format)
dryRunFormat = format.String()
}
return cmdutil.WriteDryRun(dryResult, cmdutil.DryRunOutputOptions{
Format: rctx.Format,
Format: dryRunFormat,
JqExpr: rctx.JqExpr,
CommandPath: rctx.Cmd.CommandPath(),
Identity: rctx.As(),
@@ -1172,6 +1216,35 @@ func shortcutDeclaresJSONFlag(s *Shortcut) bool {
return false
}
func shortcutDeclaresFormatFlag(s *Shortcut) bool {
for _, fl := range s.Flags {
if fl.Name == "format" {
return true
}
}
return false
}
func canonicalizeFrameworkFormatFlag(cmd *cobra.Command) (string, error) {
raw, err := cmd.Flags().GetString("format")
if err != nil {
return "", errs.NewInternalError(errs.SubtypeUnknown,
"failed to read the framework --format value").WithCause(err)
}
format, err := output.ParseFormatStrict(raw)
if err != nil {
return "", err
}
canonical := format.String()
if raw != canonical {
if err := cmd.Flags().Set("format", canonical); err != nil {
return "", errs.NewInternalError(errs.SubtypeUnknown,
"failed to canonicalize the framework --format value").WithCause(err)
}
}
return canonical, nil
}
// shortcutFormatSupportsJSON reports whether the command's format flag accepts
// "json": a self-declared format supports it only when its Enum lists "json";
// a framework-injected default format (no format entry in s.Flags) always does.

View File

@@ -5,9 +5,13 @@ package common
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/spf13/cobra"
)
@@ -37,3 +41,113 @@ func TestShortcutMount_FormatFlagAlwaysRegistered(t *testing.T) {
t.Errorf("--format default = %q, want %q", flag.DefValue, "json")
}
}
func TestRunShortcutWritePrettyWithoutRendererExecutesAndUsesGenericTable(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
executeCalls := 0
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
})
writeStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/fixture/v1/items",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "created"},
},
}
reg.Register(writeStub)
shortcut := &Shortcut{
Service: "fixture",
Command: "+write",
Risk: "write",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *RuntimeContext) error {
executeCalls++
data, err := rctx.CallAPITyped("POST", "/open-apis/fixture/v1/items", nil, map[string]interface{}{"name": "created"})
if err != nil {
return err
}
rctx.Out(data, nil)
return nil
},
}
cmd := newTestShortcutCmd(shortcut, f)
if err := cmd.Flags().Set("as", "bot"); err != nil {
t.Fatalf("set --as: %v", err)
}
if err := cmd.Flags().Set("format", "pretty"); err != nil {
t.Fatalf("set --format: %v", err)
}
if err := runShortcut(cmd, f, shortcut, true); err != nil {
t.Fatalf("runShortcut() error = %v, want nil", err)
}
if executeCalls != 1 {
t.Fatalf("Execute call count = %d, want 1", executeCalls)
}
if len(writeStub.CapturedBodies) != 1 {
t.Fatalf("API call count = %d, want 1", len(writeStub.CapturedBodies))
}
const wantStdout = "id created\n"
if stdout.String() != wantStdout {
t.Fatalf("stdout = %q, want %q", stdout.String(), wantStdout)
}
if stderr.Len() != 0 {
t.Fatalf("stderr = %q, want empty", stderr.String())
}
}
func TestRunShortcutOutHonorsSelectedFormat(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
for _, format := range []string{"json", "pretty", "ndjson", "table", "csv"} {
t.Run(format, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
})
shortcut := &Shortcut{
Service: "fixture",
Command: "+read",
Risk: "read",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *RuntimeContext) error {
rctx.Out([]interface{}{
map[string]interface{}{"id": "1", "name": "Alice"},
}, nil)
return nil
},
}
cmd := newTestShortcutCmd(shortcut, f)
if err := cmd.Flags().Set("as", "bot"); err != nil {
t.Fatalf("set --as: %v", err)
}
if err := cmd.Flags().Set("format", format); err != nil {
t.Fatalf("set --format: %v", err)
}
if err := runShortcut(cmd, f, shortcut, true); err != nil {
t.Fatalf("runShortcut() error = %v", err)
}
got := stdout.String()
if format == "json" {
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("stdout is not a JSON envelope: %v\n%s", err, got)
}
if envelope["ok"] != true {
t.Fatalf("JSON envelope ok = %#v, want true", envelope["ok"])
}
return
}
if strings.Contains(got, `"ok"`) {
t.Fatalf("%s output contains a JSON envelope: %s", format, got)
}
if !strings.Contains(got, "Alice") {
t.Fatalf("%s output = %q, want rendered data", format, got)
}
})
}
}

View File

@@ -143,6 +143,15 @@ func TestRuntimeContext_OutRaw_PropagatesWriteError(t *testing.T) {
}
}
func TestRuntimeContext_OutRaw_HonorsSelectedFormat(t *testing.T) {
rctx, stdout, _ := newJqTestContext("", "ndjson")
rctx.OutRaw([]interface{}{map[string]interface{}{"html": "<p>hello</p>"}}, nil)
if got := stdout.String(); got != "{\"html\":\"\\u003cp\\u003ehello\\u003c/p\\u003e\"}\n" {
t.Fatalf("OutRaw() stdout = %q, want NDJSON without an envelope", got)
}
}
func TestRunShortcut_OutRawWriteErrorPropagates(t *testing.T) {
sentinel := errors.New("write failed")
f := newTestFactory()
@@ -246,12 +255,15 @@ func TestRunShortcut_JqAndFormatConflict(t *testing.T) {
return nil
},
}
cmd := newTestShortcutCmd(s, newTestFactory())
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
})
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("jq", ".data")
cmd.Flags().Set("format", "table")
cmd.Flags().Set("as", "bot")
err := runShortcut(cmd, newTestFactory(), s, true)
err := runShortcut(cmd, f, s, true)
if err == nil {
t.Fatal("expected error for --jq + --format table conflict")
}
@@ -339,6 +351,207 @@ func TestRunShortcut_DryRunJSONUsesEnvelope(t *testing.T) {
}
}
func TestRunShortcut_DryRunMixedCasePrettyUsesPlainTextPreview(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
DryRun: func(context.Context, *RuntimeContext) *cmdutil.DryRunAPI {
return cmdutil.NewDryRunAPI().GET("/open-apis/test")
},
Execute: func(context.Context, *RuntimeContext) error {
t.Fatal("Execute should not run in dry-run")
return nil
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
})
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("dry-run", "true")
cmd.Flags().Set("format", "Pretty")
cmd.Flags().Set("as", "bot")
if err := runShortcut(cmd, f, s, false); err != nil {
t.Fatalf("runShortcut() error = %v", err)
}
if !strings.Contains(stdout.String(), "# dry-run: request not sent") {
t.Fatalf("dry-run --format Pretty lost its plain-text preview, stdout:\n%s", stdout.String())
}
}
func TestRunShortcut_MixedCaseFrameworkFormatIsCanonicalized(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
var runtimeFormat string
var flagFormat string
prettyBranchFired := false
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *RuntimeContext) error {
runtimeFormat = rctx.Format
flagFormat = rctx.Str("format")
prettyBranchFired = rctx.Format == "pretty"
return nil
},
}
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
})
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("format", "PRETTY")
cmd.Flags().Set("as", "bot")
if err := runShortcut(cmd, f, s, false); err != nil {
t.Fatalf("runShortcut() error = %v", err)
}
if runtimeFormat != "pretty" {
t.Fatalf("RuntimeContext.Format = %q, want pretty", runtimeFormat)
}
if flagFormat != "pretty" {
t.Fatalf("RuntimeContext.Str(\"format\") = %q, want pretty", flagFormat)
}
if !prettyBranchFired {
t.Fatal("downstream RuntimeContext.Format == \"pretty\" branch did not fire")
}
}
func TestRunShortcut_UnknownFormatErrorIncludesPretty(t *testing.T) {
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
Execute: func(context.Context, *RuntimeContext) error {
t.Fatal("Execute should not run for an unknown format")
return nil
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
})
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("format", "tabel")
cmd.Flags().Set("as", "bot")
err := runShortcut(cmd, f, s, false)
if err == nil {
t.Fatal("expected a validation error for unknown --format")
}
validationErr := requireValidation(t, err, "unknown output format")
if validationErr.Param != "--format" {
t.Fatalf("Param = %q, want --format", validationErr.Param)
}
if !strings.Contains(strings.ToLower(err.Error()), "pretty") {
t.Fatalf("shortcut unknown-format error = %v, want pretty in allowed choices", err)
}
if stdout.Len() != 0 {
t.Fatalf("unknown --format wrote stdout:\n%s", stdout.String())
}
}
func TestRunShortcut_PrintSchemaRejectsUnknownFrameworkFormat(t *testing.T) {
schemaCalled := false
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
PrintFlagSchema: func(string) ([]byte, error) {
schemaCalled = true
return []byte(`{"type":"object"}`), nil
},
Execute: func(context.Context, *RuntimeContext) error {
t.Fatal("Execute should not run for --print-schema")
return nil
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
})
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("print-schema", "true")
cmd.Flags().Set("format", "tabel")
err := runShortcut(cmd, f, s, false)
validationErr := requireValidation(t, err, "unknown output format")
if validationErr.Param != "--format" {
t.Fatalf("Param = %q, want --format", validationErr.Param)
}
if schemaCalled {
t.Fatal("PrintFlagSchema should not run after an invalid framework --format")
}
if stdout.Len() != 0 {
t.Fatalf("invalid --format wrote schema to stdout:\n%s", stdout.String())
}
}
func TestRunShortcut_PrintSchemaRejectsKnownNonJSONFormat(t *testing.T) {
schemaCalled := false
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
PrintFlagSchema: func(string) ([]byte, error) {
schemaCalled = true
return []byte(`{"type":"object"}`), nil
},
Execute: func(context.Context, *RuntimeContext) error {
t.Fatal("Execute should not run for --print-schema")
return nil
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
})
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("print-schema", "true")
cmd.Flags().Set("format", "csv")
err := runShortcut(cmd, f, s, false)
validationErr := requireValidation(t, err, "requires --format json")
if validationErr.Param != "--format" {
t.Fatalf("Param = %q, want --format", validationErr.Param)
}
if schemaCalled {
t.Fatal("PrintFlagSchema should not run for a non-JSON format")
}
if stdout.Len() != 0 {
t.Fatalf("non-JSON format wrote schema to stdout:\n%s", stdout.String())
}
}
func TestRunShortcut_UnknownFormatPrecedesJqConflict(t *testing.T) {
s := &Shortcut{
Service: "test",
Command: "test-shortcut",
AuthTypes: []string{"bot"},
Execute: func(context.Context, *RuntimeContext) error {
t.Fatal("Execute should not run for an unknown format")
return nil
},
}
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test", AppSecret: "test", Brand: core.BrandFeishu,
})
cmd := newTestShortcutCmd(s, f)
cmd.Flags().Set("format", "tabel")
cmd.Flags().Set("jq", ".")
cmd.Flags().Set("as", "bot")
err := runShortcut(cmd, f, s, false)
validationErr := requireValidation(t, err, "unknown output format")
if validationErr.Param != "--format" {
t.Fatalf("Param = %q, want --format", validationErr.Param)
}
if strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("error = %v, unknown format should be reported before jq conflict", err)
}
if stdout.Len() != 0 {
t.Fatalf("unknown --format wrote stdout:\n%s", stdout.String())
}
}
func TestRunShortcut_DryRunWithJq(t *testing.T) {
s := &Shortcut{
Service: "test",

View File

@@ -5,8 +5,8 @@ package common
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
@@ -16,14 +16,14 @@ import (
"github.com/larksuite/cli/internal/output"
)
// TestOutPartialFailure pins the batch / multi-status contract: the result
// rides on stdout as an ok:false envelope (carrying the full payload), and the
// returned error is the typed partial-failure exit signal (ExitAPI), distinct
// from ErrBare (the silent-exit signal).
// TestOutPartialFailure pins the batch / multi-status contract: stdout honors
// the selected format and still carries the full payload, while the returned
// error is the typed partial-failure exit signal.
func TestOutPartialFailure(t *testing.T) {
cfg := &core.CliConfig{Brand: core.BrandFeishu, AppID: "cli_x"}
f, stdout, _, _ := cmdutil.TestFactory(t, cfg)
rt := TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "+push"}, cfg, f, core.AsUser)
rt.Format = "table"
payload := map[string]interface{}{
"summary": map[string]interface{}{"uploaded": 1, "failed": 1},
@@ -44,20 +44,15 @@ func TestOutPartialFailure(t *testing.T) {
t.Errorf("exit code = %d, want %d (ExitAPI)", pfErr.Code, output.ExitAPI)
}
// 2) stdout envelope reports ok:false but still carries the full payload
// (both the succeeded and failed items) — consistent with a success Out().
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
// 2) table output contains both successful and failed outcomes and does not
// silently switch to a JSON envelope.
got := stdout.String()
for _, want := range []string{"a.txt", "uploaded", "b.txt", "failed", "boom"} {
if !strings.Contains(got, want) {
t.Fatalf("stdout missing %q:\n%s", want, got)
}
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("unmarshal stdout envelope: %v\nstdout: %s", err, stdout.String())
}
if env.OK {
t.Errorf("ok must be false on partial failure, got ok:true\nstdout: %s", stdout.String())
}
items, _ := env.Data["items"].([]interface{})
if len(items) != 2 {
t.Fatalf("both succeeded and failed items must ride on stdout, got %d items\nstdout: %s", len(items), stdout.String())
if strings.Contains(got, `"ok"`) {
t.Fatalf("table output contains JSON envelope:\n%s", got)
}
}

View File

@@ -15,6 +15,7 @@ import (
"sync/atomic"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/internal/vfs"
@@ -27,6 +28,7 @@ const dedupTTL = 5 * time.Minute
type PipelineConfig struct {
Mode TransformMode // determined by --compact flag
JsonFlag bool // --json: pretty JSON instead of NDJSON
Format string // explicit stdout format: json or ndjson
OutputDir string // --output-dir: write events to files
Quiet bool // --quiet: suppress stderr status messages
Router *EventRouter // --route: regex-based output routing
@@ -39,7 +41,8 @@ type EventPipeline struct {
config PipelineConfig
eventCount atomic.Int64
seen sync.Map // key → time.Time (first-seen timestamp)
out io.Writer
emitMu sync.Mutex
emitter *output.Emitter
errOut io.Writer
}
@@ -50,12 +53,17 @@ func NewEventPipeline(
config PipelineConfig,
out, errOut io.Writer,
) *EventPipeline {
commandPath := "lark-cli event +subscribe"
return &EventPipeline{
registry: registry,
filters: filters,
config: config,
out: out,
errOut: errOut,
emitter: output.NewEmitter(output.EmitterConfig{
Out: out,
ErrOut: errOut,
CommandPath: commandPath,
}),
errOut: errOut,
}
}
@@ -109,12 +117,12 @@ func (p *EventPipeline) cleanupSeen(now time.Time) {
}
// Process is the pipeline entry point, called by the WebSocket callback.
func (p *EventPipeline) Process(ctx context.Context, raw *RawEvent) {
func (p *EventPipeline) Process(ctx context.Context, raw *RawEvent) error {
eventType := raw.Header.EventType
// 1. Filter
if !p.filters.Allow(eventType) {
return
return nil
}
// 2. Lookup processor
@@ -123,7 +131,7 @@ func (p *EventPipeline) Process(ctx context.Context, raw *RawEvent) {
// 3. Dedup
if key := processor.DeduplicateKey(raw); key != "" && p.isDuplicate(key) {
p.infof("%s[dedup]%s %s (key=%s)", output.Dim, output.Reset, eventType, key)
return
return nil
}
n := p.eventCount.Add(1)
@@ -141,23 +149,34 @@ func (p *EventPipeline) Process(ctx context.Context, raw *RawEvent) {
for _, dir := range dirs {
p.writeAndLog(dir, n, eventType, data, raw.Header)
}
return
return nil
}
}
// 5b. --output-dir
if p.config.OutputDir != "" {
p.writeAndLog(p.config.OutputDir, n, eventType, data, raw.Header)
return
return nil
}
// 5c. Stdout
if p.config.JsonFlag {
output.PrintJson(p.out, data)
} else {
output.PrintNdjson(p.out, data)
format := output.FormatNDJSON
switch {
case p.config.JsonFlag || p.config.Format == output.FormatJSON.String():
format = output.FormatJSON
case p.config.Format == "", p.config.Format == output.FormatNDJSON.String():
default:
return errs.NewInternalError(errs.SubtypeUnknown,
"internal: unsupported event pipeline format %q", p.config.Format)
}
p.emitMu.Lock()
err := p.emitter.Value(data, output.StreamOptions{Format: format})
p.emitMu.Unlock()
if err != nil {
return err
}
p.infof("%s[%d]%s %s", output.Dim, n, output.Reset, eventType)
return nil
}
// writeAndLog writes an event to a directory and logs the result.

View File

@@ -8,6 +8,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@@ -17,6 +18,7 @@ import (
"time"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/lockfile"
@@ -25,6 +27,24 @@ import (
"github.com/spf13/cobra"
)
type eventBlockingSafetyProvider struct{}
func (eventBlockingSafetyProvider) Name() string { return "event-test" }
func (eventBlockingSafetyProvider) Scan(_ context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
encoded, _ := json.Marshal(req.Data)
var normalized any
_ = json.Unmarshal(encoded, &normalized)
if strings.Contains(fmt.Sprint(normalized), "<system>") {
return &extcs.Alert{Provider: "event-test", MatchedRules: []string{"role-injection"}}, nil
}
return nil, nil
}
func (p eventBlockingSafetyProvider) ScanFullText(ctx context.Context, req extcs.ScanRequest) (*extcs.Alert, error) {
return p.Scan(ctx, req)
}
// chdirTemp changes cwd to a fresh temp dir for the test duration.
func chdirTemp(t *testing.T) {
t.Helper()
@@ -600,6 +620,26 @@ func TestPipeline_JsonFlag(t *testing.T) {
}
}
func TestPipeline_BlockModeScansEventBeforeStdout(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "block")
extcs.Register(eventBlockingSafetyProvider{})
t.Cleanup(func() { extcs.Register(nil) })
filters := NewFilterChain()
var out, errOut bytes.Buffer
p := NewEventPipeline(DefaultRegistry(), filters,
PipelineConfig{Mode: TransformRaw, Format: "ndjson"}, &out, &errOut)
err := p.Process(context.Background(), makeRawEvent("drive.file.edit_v1", `{"text":"<system>"}`))
var safetyErr *errs.ContentSafetyError
if !errors.As(err, &safetyErr) {
t.Fatalf("Process() error = %T, want *errs.ContentSafetyError", err)
}
if out.Len() != 0 {
t.Fatalf("Process() stdout = %q, want empty", out.String())
}
}
// --- Pipeline: Quiet ---
func TestPipeline_Quiet(t *testing.T) {

View File

@@ -98,7 +98,8 @@ var EventSubscribe = common.Shortcut{
{Name: "route", Type: "string_array", Desc: "regex-based event routing (e.g. --route '^im\\.message=dir:./im/' --route '^contact\\.=dir:./contacts/'); unmatched events fall through to --output-dir or stdout"},
// Output format — how events are serialized
{Name: "compact", Type: "bool", Desc: "flat key-value output: extract text, strip noise fields"},
{Name: "json", Type: "bool", Desc: "pretty-print JSON instead of NDJSON"},
{Name: "format", Default: "ndjson", Enum: []string{"json", "ndjson"}, Desc: "stdout format: json (pretty-printed event objects) or ndjson (one compact object per line)"},
{Name: "json", Type: "bool", Desc: "alias for --format json"},
// Filtering — which events reach the pipeline
{Name: "event-types", Desc: "comma-separated event types to subscribe; only use when you do not need other events (omit for catch-all)"},
{Name: "filter", Desc: "regex to further filter events by event_type"},
@@ -106,6 +107,17 @@ var EventSubscribe = common.Shortcut{
{Name: "quiet", Type: "bool", Desc: "suppress stderr status messages"},
{Name: "force", Type: "bool", Desc: "bypass single-instance lock (UNSAFE: server randomly splits events across connections, each instance only receives a subset)"},
},
Validate: func(_ context.Context, runtime *common.RuntimeContext) error {
if runtime.Bool("json") &&
runtime.Cmd.Flags().Changed("format") &&
runtime.Str("format") != output.FormatJSON.String() {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--json conflicts with --format %s", runtime.Str("format")).
WithParam("--format").
WithHint("use --format json or remove --json")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
eventTypesDisplay := "(catch-all)"
if s := runtime.Str("event-types"); s != "" {
@@ -123,18 +135,27 @@ var EventSubscribe = common.Shortcut{
if routes := runtime.StrArray("route"); len(routes) > 0 {
routeDisplay = strings.Join(routes, "; ")
}
formatDisplay := runtime.Str("format")
if runtime.Bool("json") {
formatDisplay = output.FormatJSON.String()
}
return common.NewDryRunAPI().
Desc("Subscribe to Lark events via WebSocket (long-running)").
Set("command", "event +subscribe").
Set("app_id", runtime.Config.AppID).
Set("event_types", eventTypesDisplay).
Set("filter", filterDisplay).Set("output_dir", outputDirDisplay).
Set("route", routeDisplay)
Set("route", routeDisplay).
Set("format", formatDisplay)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
eventTypesStr := runtime.Str("event-types")
filterStr := runtime.Str("filter")
jsonFlag := runtime.Bool("json")
formatName := runtime.Str("format")
if jsonFlag {
formatName = output.FormatJSON.String()
}
compactFlag := runtime.Bool("compact")
outputDir := runtime.Str("output-dir")
quietFlag := runtime.Bool("quiet")
@@ -207,7 +228,7 @@ var EventSubscribe = common.Shortcut{
}
pipeline := NewEventPipeline(DefaultRegistry(), filters, PipelineConfig{
Mode: mode,
JsonFlag: jsonFlag,
Format: formatName,
OutputDir: outputDir,
Quiet: quietFlag,
Router: router,
@@ -227,8 +248,7 @@ var EventSubscribe = common.Shortcut{
output.PrintError(errOut, fmt.Sprintf("failed to parse event: %v", err))
return nil
}
pipeline.Process(ctx, &raw)
return nil
return pipeline.Process(ctx, &raw)
}
sdkLogger := &stderrLogger{w: errOut, quiet: quietFlag}

View File

@@ -210,7 +210,7 @@ func printMessageOutputSchema(runtime *common.RuntimeContext) {
// printWatchOutputSchema prints the per-format field reference for +watch output.
// Used by --print-output-schema to let callers discover field names without reading skill docs.
func printWatchOutputSchema(runtime *common.RuntimeContext) {
func printWatchOutputSchema(runtime *common.RuntimeContext) error {
schema := map[string]interface{}{
"minimal": map[string]interface{}{
"message": map[string]interface{}{
@@ -276,8 +276,7 @@ func printWatchOutputSchema(runtime *common.RuntimeContext) {
},
},
}
b, _ := json.MarshalIndent(schema, "", " ")
fmt.Fprintln(runtime.IO().Out, string(b))
return runtime.EmitValue(schema, "json")
}
// resolveMailboxID returns the user_mailbox_id from --mailbox flag, defaulting to "me".

View File

@@ -421,7 +421,7 @@ func printTriageFilterSchema(runtime *common.RuntimeContext) {
`{"folder":"SENT","time_range":{"start_time":"2026-03-01T00:00:00+08:00"}}`,
},
}
runtime.Out(schema, nil)
runtime.OutJSON(schema, nil)
}
func parseTriageFilter(filterStr string) (triageFilter, error) {

View File

@@ -834,12 +834,17 @@ func TestMergeTriageLabels(t *testing.T) {
func TestPrintTriageFilterSchema(t *testing.T) {
rt := runtimeForMailTriageTest(t, nil)
rt.Format = "data"
var buf strings.Builder
rt.Factory = &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: &buf, ErrOut: &buf}}
printTriageFilterSchema(rt)
if !strings.Contains(buf.String(), "folder") {
t.Fatal("schema output should contain 'folder'")
}
var envelope map[string]interface{}
if err := json.Unmarshal([]byte(buf.String()), &envelope); err != nil {
t.Fatalf("schema output is not a JSON envelope: %v\n%s", err, buf.String())
}
}
// --- resolveSearchFolderFilter / resolveSearchLabelFilter (dry-run) ---

View File

@@ -179,8 +179,7 @@ var MailWatch = common.Shortcut{
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
if runtime.Bool("print-output-schema") {
printWatchOutputSchema(runtime)
return nil
return printWatchOutputSchema(runtime)
}
mailbox := resolveMailboxID(runtime)
hintIdentityFirst(runtime, mailbox)
@@ -217,8 +216,6 @@ var MailWatch = common.Shortcut{
foldersInput := runtime.Str("folders")
errOut := runtime.IO().ErrOut
out := runtime.IO().Out
info := func(msg string) {
fmt.Fprintln(errOut, msg)
}
@@ -286,7 +283,7 @@ var MailWatch = common.Shortcut{
var eventCount atomic.Int64
handleEvent := func(data map[string]interface{}) {
handleEvent := func(data map[string]interface{}) error {
// Extract event body
eventBody := extractMailEventBody(data)
@@ -294,13 +291,13 @@ var MailWatch = common.Shortcut{
if mailboxFilter != "" {
mailAddr, _ := eventBody["mail_address"].(string)
if !strings.EqualFold(mailAddr, mailboxFilter) {
return
return nil
}
}
messageID, _ := eventBody["message_id"].(string)
if messageID == "" {
return
return nil
}
// Use event's mail_address as the fetch mailbox when available,
@@ -332,8 +329,7 @@ var MailWatch = common.Shortcut{
output.PrintError(errOut, fmt.Sprintf("failed to write event file: %v", writeErr))
}
}
output.PrintJson(out, failureData)
return
return runtime.EmitValue(failureData, output.FormatNDJSON.String())
}
}
@@ -341,12 +337,12 @@ var MailWatch = common.Shortcut{
if len(folderIDSet) > 0 {
folderID, _ := message["folder_id"].(string)
if !folderIDSet[folderID] {
return
return nil
}
}
if len(labelIDSet) > 0 {
if !messageHasLabel(message, labelIDSet) {
return
return nil
}
}
@@ -387,10 +383,14 @@ var MailWatch = common.Shortcut{
switch outFormat {
case "json", "":
output.PrintNdjson(out, output.Envelope{OK: true, Identity: string(runtime.As()), Data: outputData})
return runtime.EmitValue(
output.Envelope{OK: true, Identity: string(runtime.As()), Data: outputData},
output.FormatNDJSON.String(),
)
case "data":
output.PrintNdjson(out, outputData)
return runtime.EmitValue(outputData, output.FormatNDJSON.String())
}
return nil
}
rawHandler := func(ctx context.Context, event *larkevent.EventReq) error {
@@ -405,8 +405,7 @@ var MailWatch = common.Shortcut{
if eventData == nil {
eventData = make(map[string]interface{})
}
handleEvent(eventData)
return nil
return handleEvent(eventData)
}
sdkLogger := &mailWatchLogger{w: errOut}

View File

@@ -329,7 +329,7 @@ var MinutesSearch = common.Shortcut{
output.PrintTable(w, rows)
})
if hasMore && runtime.Format != "json" && runtime.Format != "" {
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
fmt.Fprintf(runtime.IO().ErrOut, "\n(more available, page_token: %s)\n", pageToken)
}
return nil
},

View File

@@ -476,7 +476,7 @@ func TestMinutesSearchDryRun(t *testing.T) {
// TestMinutesSearchExecuteRendersRowsAndMoreHint verifies pretty output renders rows and pagination hints.
func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
f, stdout, stderr, reg := cmdutil.TestFactory(t, defaultConfig())
searchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/minutes/v1/minutes/search",
@@ -526,11 +526,16 @@ func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) {
}
out := stdout.String()
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "next_token", "more available"} {
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q, got: %s", want, out)
}
}
for _, want := range []string{"next_token", "more available"} {
if !strings.Contains(stderr.String(), want) {
t.Fatalf("stderr missing %q, got: %s", want, stderr.String())
}
}
}
// TestMinutesSearchExecuteNoMinutes verifies empty results render the no-data message.
@@ -563,11 +568,12 @@ func TestMinutesSearchExecuteNoMinutes(t *testing.T) {
}
}
// TestMinutesSearchExecuteShowsPaginationHintForTableFormat verifies table output includes pagination hints.
// TestMinutesSearchExecuteShowsPaginationHintForTableFormat verifies pagination
// hints stay on stderr so table stdout remains parseable.
func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
f, stdout, stderr, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/minutes/v1/minutes/search",
@@ -599,9 +605,11 @@ func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
}
reg.Verify(t)
out := stdout.String()
if !strings.Contains(out, "next_token") || !strings.Contains(out, "more available") {
t.Fatalf("expected pagination hint in table output, got: %s", out)
if strings.Contains(stdout.String(), "next_token") || strings.Contains(stdout.String(), "more available") {
t.Fatalf("table stdout contains pagination hint: %s", stdout.String())
}
if !strings.Contains(stderr.String(), "next_token") || !strings.Contains(stderr.String(), "more available") {
t.Fatalf("stderr missing pagination hint: %s", stderr.String())
}
}

View File

@@ -265,7 +265,7 @@ var VCSearch = common.Shortcut{
// 非 json 格式下追加分页提示json 格式已包含 has_more/page_token 字段)
if hasMore && runtime.Format != "json" && runtime.Format != "" {
pt, _ := data["page_token"].(string)
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pt)
fmt.Fprintf(runtime.IO().ErrOut, "\n(more available, page_token: %s)\n", pt)
}
return nil
},

View File

@@ -15,6 +15,7 @@ import (
)
func TestBaseRecordBatchUpdatePerRecordWorkflow(t *testing.T) {
clie2e.SkipWithoutTenantAccessToken(t)
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)

View File

@@ -44,6 +44,7 @@ func TestDocs_CreateAndFetchWorkflowAsBot(t *testing.T) {
"--doc", docToken,
"--doc-format", "markdown",
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -28,6 +28,7 @@ func TestEventSubscribeDryRun(t *testing.T) {
"--filter", "^im\\.",
"--output-dir", "events_out",
"--route", "^im\\.message=dir:./messages",
"--format", "json",
"--dry-run",
},
DefaultAs: "bot",
@@ -42,4 +43,5 @@ func TestEventSubscribeDryRun(t *testing.T) {
require.Equal(t, "^im\\.", clie2e.DryRunGet(out, "filter").String(), "stdout:\n%s", out)
require.Equal(t, "events_out", clie2e.DryRunGet(out, "output_dir").String(), "stdout:\n%s", out)
require.Equal(t, "^im\\.message=dir:./messages", clie2e.DryRunGet(out, "route").String(), "stdout:\n%s", out)
require.Equal(t, "json", clie2e.DryRunGet(out, "format").String(), "stdout:\n%s", out)
}

View File

@@ -366,6 +366,7 @@ func createTestObjectives(t *testing.T, ctx context.Context, cycleID string, suf
"--cycle-id", cycleID,
"--input", string(inputJSON),
},
DefaultAs: "user",
})
require.NoError(t, err, "failed to create test objectives")
result.AssertExitCode(t, 0)
@@ -411,6 +412,7 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
"--key-result-id", krID,
"--yes",
},
DefaultAs: "user",
})
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete KR %s", krID), result, err)
select {
@@ -426,6 +428,7 @@ func cleanupLiveTest(t *testing.T, created []liveTestCreated) {
"--objective-id", obj.ObjectiveID,
"--yes",
},
DefaultAs: "user",
})
clie2e.ReportCleanupFailure(t, fmt.Sprintf("delete objective %s", obj.ObjectiveID), result, err)
if i > 0 {
@@ -447,6 +450,7 @@ func createLiveObjective(t *testing.T, ctx context.Context, cycleID string, suff
"--cycle-id", cycleID,
"--content", fmt.Sprintf(`{"text":"E2E Single Objective %s","mention":["ou_test"]}`, suffix),
},
DefaultAs: "user",
})
require.NoError(t, err, "failed to create live objective")
result.AssertExitCode(t, 0)
@@ -466,6 +470,7 @@ func createLiveKeyResult(t *testing.T, ctx context.Context, objectiveID string,
"--objective-id", objectiveID,
"--content", fmt.Sprintf(`{"text":"E2E Single KR %s","mention":["ou_test"]}`, suffix),
},
DefaultAs: "user",
})
require.NoError(t, err, "failed to create live key result")
result.AssertExitCode(t, 0)
@@ -499,6 +504,7 @@ func TestOKR_BatchCreateLive(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -544,6 +550,7 @@ func TestOKR_CreateLive_Objective(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -581,6 +588,7 @@ func TestOKR_CreateLive_KeyResultUnderExistingObjective(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -640,6 +648,7 @@ func TestOKR_ReorderLive(t *testing.T) {
"--level", "objective",
"--ops", string(opsJSON),
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -650,6 +659,7 @@ func TestOKR_ReorderLive(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -702,6 +712,7 @@ func TestOKR_WeightLive(t *testing.T) {
"--level", "objective",
"--weights", string(weightsJSON),
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
@@ -712,6 +723,7 @@ func TestOKR_WeightLive(t *testing.T) {
"okr", "+cycle-detail",
"--cycle-id", cycleID,
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)

View File

@@ -0,0 +1,66 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func setTaskFormatDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "task_format_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "task_format_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
func TestTaskDryRunMixedCasePrettyUsesPlainTextPreview(t *testing.T) {
setTaskFormatDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"task", "+get-my-tasks",
"--dry-run",
"--format", "Pretty",
},
DefaultAs: "user",
})
require.NoError(t, err)
require.NoError(t, result.RunErr, "stderr:\n%s", result.Stderr)
result.AssertExitCode(t, 0)
require.True(t, strings.HasPrefix(result.Stdout, "# dry-run: request not sent\n"), "stdout:\n%s", result.Stdout)
require.Contains(t, result.Stdout, "/open-apis/task/v2/tasks", "stdout:\n%s", result.Stdout)
require.False(t, strings.HasPrefix(strings.TrimSpace(result.Stdout), "{"), "stdout must be plain text:\n%s", result.Stdout)
}
func TestTaskDryRunUnknownFormatReturnsTypedValidationBeforePreview(t *testing.T) {
setTaskFormatDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"task", "+get-my-tasks",
"--dry-run",
"--format", "yaml",
},
DefaultAs: "user",
})
require.NoError(t, err)
require.Error(t, result.RunErr)
result.AssertExitCode(t, 2)
require.Empty(t, strings.TrimSpace(result.Stdout), "request preview must not be emitted:\n%s", result.Stdout)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), "stderr:\n%s", result.Stderr)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), "stderr:\n%s", result.Stderr)
require.Equal(t, "--format", gjson.Get(result.Stderr, "error.param").String(), "stderr:\n%s", result.Stderr)
}