mirror of
https://github.com/larksuite/cli.git
synced 2026-08-03 08:32:46 +08:00
Compare commits
26 Commits
v1.0.73
...
codex/fix-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15263efe30 | ||
|
|
5b67085b32 | ||
|
|
da149e66ba | ||
|
|
80323bb464 | ||
|
|
0a33bd7c57 | ||
|
|
aafaed06a7 | ||
|
|
54ddcf490b | ||
|
|
bb246b591f | ||
|
|
fc2761d16b | ||
|
|
409a3172da | ||
|
|
483aadee3b | ||
|
|
e43f497650 | ||
|
|
990d633c07 | ||
|
|
d4168ab84f | ||
|
|
12ca42c953 | ||
|
|
d382ee9053 | ||
|
|
daaacb4977 | ||
|
|
680501c1df | ||
|
|
6675e3c247 | ||
|
|
7b48709438 | ||
|
|
c876841106 | ||
|
|
4c1a92caa6 | ||
|
|
577ff035c3 | ||
|
|
4b4ca4283a | ||
|
|
ad4a6d68c7 | ||
|
|
d8fb368ce4 |
@@ -10,9 +10,10 @@
|
||||
## Build & Test
|
||||
|
||||
```bash
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make test # Full: vet + unit + integration
|
||||
make build # Build (runs fetch_meta first)
|
||||
make unit-test # Required before PR (runs with -race where supported, e.g. amd64/arm64)
|
||||
make live-skills-test # Opt-in real Skills CLI tests; runs with isolated user directories
|
||||
make test # Full: vet + unit + integration
|
||||
```
|
||||
|
||||
## Notification Opt-Outs
|
||||
|
||||
30
CHANGELOG.md
30
CHANGELOG.md
@@ -2,6 +2,35 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.74] - 2026-07-21
|
||||
|
||||
### Features
|
||||
|
||||
- **slides**: add history rollback shortcuts (#1714)
|
||||
- **base**: support per-record batch updates (#1889)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- preserve slides schema issues
|
||||
- allow jq examples in quality gate dry-runs
|
||||
- **im**: warn when flag pagination is truncated (#1906)
|
||||
- **slides**: warn on text shape overflow
|
||||
- **slides**: exempt chart roundtrip attributes from lint
|
||||
- **slides**: detect image text occlusion
|
||||
- **slides**: clarify xml-text-overlap-lint error for positional argument (#1986)
|
||||
|
||||
### Documentation
|
||||
|
||||
- clarify drive upload overwrite guidance (#1982)
|
||||
|
||||
### Tests
|
||||
|
||||
- isolate unit tests from user state (#1883)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- converge success output through a single Emitter that owns the write (#1899)
|
||||
|
||||
## [v1.0.73] - 2026-07-20
|
||||
|
||||
### Features
|
||||
@@ -1579,6 +1608,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.74]: https://github.com/larksuite/cli/releases/tag/v1.0.74
|
||||
[v1.0.73]: https://github.com/larksuite/cli/releases/tag/v1.0.73
|
||||
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
|
||||
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
|
||||
|
||||
7
Makefile
7
Makefile
@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
|
||||
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
|
||||
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
|
||||
|
||||
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
.PHONY: all build vet fmt-check script-test test unit-test live-skills-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
|
||||
|
||||
all: test
|
||||
|
||||
@@ -58,6 +58,11 @@ unit-test: fetch_meta
|
||||
go test $(RACE_FLAG) -gcflags="all=-N -l" -count=1 \
|
||||
./cmd/... ./internal/... ./shortcuts/... ./extension/...
|
||||
|
||||
live-skills-test: fetch_meta
|
||||
LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS=1 \
|
||||
go test -v -count=1 ./cmd/update \
|
||||
-run '^TestUpdateCommand_(RealSkillsSyncRewritesState|SkillsSyncColdStart)$$'
|
||||
|
||||
# examples-build keeps the shipped plugin-SDK examples compilable. If this
|
||||
# breaks, the plugin author guide's "go build ./..." path is broken.
|
||||
examples-build:
|
||||
|
||||
@@ -344,20 +344,18 @@ func apiPaginate(ctx context.Context, ac *client.APIClient, request client.RawAp
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
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.
|
||||
scanResult := output.ScanForSafety(commandPath, items, errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
pf.FormatPage(items)
|
||||
return nil
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return errs.MarkRaw(err)
|
||||
|
||||
396
cmd/api/api_paginate_test.go
Normal file
396
cmd/api/api_paginate_test.go
Normal file
@@ -0,0 +1,396 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type apiFailOnWriteWriter struct {
|
||||
buf bytes.Buffer
|
||||
writes int
|
||||
failAt int
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *apiFailOnWriteWriter) Write(p []byte) (int, error) {
|
||||
w.writes++
|
||||
if w.writes == w.failAt {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func newAPIPaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
previousNotice := output.PendingNotice
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() { output.PendingNotice = previousNotice })
|
||||
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, out, errOut, reg := cmdutil.TestFactory(t, config)
|
||||
ac, err := f.NewAPIClientWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
|
||||
}
|
||||
ac.ErrOut = io.Discard
|
||||
return ac, out, errOut, reg
|
||||
}
|
||||
|
||||
func apiPaginateRequest() client.RawApiRequest {
|
||||
return client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test/v1/items",
|
||||
As: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func assertAPIPaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
|
||||
t.Helper()
|
||||
wantBytes, err := json.MarshalIndent(want, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal expected JSON: %v", err)
|
||||
}
|
||||
wantBytes = append(wantBytes, '\n')
|
||||
if !bytes.Equal(got, wantBytes) {
|
||||
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_DefaultAggregatesAllPages(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
calls := 0
|
||||
wantTokens := []string{"", "next-1", "next-2"}
|
||||
for i, wantToken := range wantTokens {
|
||||
page := i + 1
|
||||
hasMore := page < len(wantTokens)
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = wantTokens[page]
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(req *http.Request) {
|
||||
calls++
|
||||
if got := req.URL.Query().Get("page_token"); got != wantToken {
|
||||
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() error = %v, want nil", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("pagination requests = %d, want 3", calls)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1"},
|
||||
map[string]interface{}{"id": "2"},
|
||||
map[string]interface{}{"id": "3"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
})
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ndjson",
|
||||
format: output.FormatNDJSON,
|
||||
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
format: output.FormatTable,
|
||||
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
format: output.FormatCSV,
|
||||
want: "id,name\n1,Alice\n2,Carol\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, "", out, errOut, "lark-cli api GET", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() 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)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
||||
ac, _, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
sentinel := errors.New("page write failed")
|
||||
out := &apiFailOnWriteWriter{failAt: 2, err: sentinel}
|
||||
calls := 0
|
||||
for page := 1; page <= 2; page++ {
|
||||
hasMore := true
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": page}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = fmt.Sprintf("next-%d", page)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(*http.Request) {
|
||||
calls++
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET",
|
||||
client.PaginationOptions{PageLimit: 10, PageDelay: -1})
|
||||
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("apiPaginate() 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)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("pagination requests = %d, want 2", calls)
|
||||
}
|
||||
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
|
||||
t.Fatalf("stdout bytes = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("apiPaginate() 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_BusinessErrorsWriteRawAndAreMarkedRaw(t *testing.T) {
|
||||
businessResponse := map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{"detail": "business failed"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "default_json", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: businessResponse,
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want business error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
assertAPIPaginateJSONBytes(t, out.Bytes(), businessResponse)
|
||||
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
|
||||
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_TransportErrorsAreMarkedRaw(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "stream_pages", format: output.FormatNDJSON},
|
||||
{name: "default_paginate_all", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(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})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want transport error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIPaginate_StreamBusinessErrorIsMarkedRaw(t *testing.T) {
|
||||
ac, out, errOut, reg := newAPIPaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := apiPaginate(context.Background(), ac, apiPaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli api GET", client.PaginationOptions{PageDelay: -1})
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("apiPaginate() error = nil, want business error")
|
||||
}
|
||||
if !errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = false, want true; error = %T: %v", err, err)
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -352,6 +352,9 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cmdutil.TestChdir(t, dir)
|
||||
|
||||
f, stdout, stderr, reg := cmdutil.TestFactory(t, &core.CliConfig{
|
||||
AppID: "test-app-bin", AppSecret: "test-secret-bin", Brand: core.BrandFeishu,
|
||||
})
|
||||
@@ -371,8 +374,33 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), "binary response detected") {
|
||||
t.Error("expected binary response hint in stderr")
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "saved_path") {
|
||||
t.Error("expected saved_path in output")
|
||||
var got map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\nstdout:\n%s", err, stdout.String())
|
||||
}
|
||||
savedPath, _ := got["saved_path"].(string)
|
||||
if savedPath == "" {
|
||||
t.Fatalf("saved_path missing from output: %#v", got)
|
||||
}
|
||||
// The file must land inside the temporary cwd — this pins the isolation
|
||||
// contract: rolling back TestChdir would leave download.bin in the repo.
|
||||
wantDir, err := filepath.EvalSymlinks(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotDir, err := filepath.EvalSymlinks(filepath.Dir(savedPath))
|
||||
if err != nil {
|
||||
t.Fatalf("saved_path %q dir not resolvable: %v", savedPath, err)
|
||||
}
|
||||
if gotDir != wantDir {
|
||||
t.Errorf("saved_path %q is outside temp cwd %q", savedPath, wantDir)
|
||||
}
|
||||
content, err := os.ReadFile(savedPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read saved file: %v", err)
|
||||
}
|
||||
if string(content) != "fake-binary-content" {
|
||||
t.Errorf("saved file content = %q, want %q", content, "fake-binary-content")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
cmd/auth/testmain_test.go
Normal file
46
cmd/auth/testmain_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/registrytest"
|
||||
)
|
||||
|
||||
// TestMain isolates auth command tests from the host machine: config, logs
|
||||
// and the registry cache are redirected to a temp dir, then the registry is
|
||||
// seeded from the tracked fixture and initialized eagerly. Domain-completion
|
||||
// tests read the registry, so without seeding a clean checkout would either
|
||||
// fail or trigger a remote metadata fetch.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
|
||||
// m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmd-auth-test-*")
|
||||
if err != nil {
|
||||
println("cmd/auth test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
println("cmd/auth test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
|
||||
println("cmd/auth test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := registrytest.Seed(root); err != nil {
|
||||
println("cmd/auth test setup: registrytest.Seed failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -371,10 +371,11 @@ func TestIntegration_StrictModeUser_ProfileOverride_ShortcutExplicitBotReturnsEn
|
||||
|
||||
func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnvelope(t *testing.T) {
|
||||
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeBot)
|
||||
rootCmd := buildStrictModeIntegrationRootCmd(t, f)
|
||||
catalog := strictModeFixtureCatalog()
|
||||
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
|
||||
|
||||
code := executeRootIntegration(t, f, rootCmd, []string{
|
||||
"im", "chats", "get", "--params", `{"chat_id":"oc_test"}`, "--as", "user", "--dry-run",
|
||||
"fixture", "things", "create", "--data", `{"name":"probe"}`, "--as", "user", "--dry-run",
|
||||
})
|
||||
|
||||
if code != output.ExitValidation {
|
||||
|
||||
@@ -707,20 +707,18 @@ func servicePaginate(ctx context.Context, ac *client.APIClient, request client.R
|
||||
|
||||
switch format {
|
||||
case output.FormatNDJSON, output.FormatTable, output.FormatCSV:
|
||||
pf := output.NewPaginatedFormatter(out, format)
|
||||
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.
|
||||
scanResult := output.ScanForSafety(commandPath, items, errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(errOut, scanResult.Alert)
|
||||
}
|
||||
pf.FormatPage(items)
|
||||
return nil
|
||||
return emitter.StreamPage(items, output.StreamOptions{Format: format.String()})
|
||||
}, pagOpts)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
400
cmd/service/service_paginate_test.go
Normal file
400
cmd/service/service_paginate_test.go
Normal file
@@ -0,0 +1,400 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/client"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type serviceFailOnWriteWriter struct {
|
||||
buf bytes.Buffer
|
||||
writes int
|
||||
failAt int
|
||||
err error
|
||||
}
|
||||
|
||||
func (w *serviceFailOnWriteWriter) Write(p []byte) (int, error) {
|
||||
w.writes++
|
||||
if w.writes == w.failAt {
|
||||
return 0, w.err
|
||||
}
|
||||
return w.buf.Write(p)
|
||||
}
|
||||
|
||||
func newServicePaginateTestHarness(t *testing.T) (*client.APIClient, *bytes.Buffer, *bytes.Buffer, *httpmock.Registry) {
|
||||
t.Helper()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
previousNotice := output.PendingNotice
|
||||
output.PendingNotice = nil
|
||||
t.Cleanup(func() { output.PendingNotice = previousNotice })
|
||||
|
||||
config := &core.CliConfig{
|
||||
AppID: "test-app",
|
||||
AppSecret: "test-secret",
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
f, out, errOut, reg := cmdutil.TestFactory(t, config)
|
||||
ac, err := f.NewAPIClientWithConfig(config)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAPIClientWithConfig() error = %v", err)
|
||||
}
|
||||
ac.ErrOut = io.Discard
|
||||
return ac, out, errOut, reg
|
||||
}
|
||||
|
||||
func servicePaginateRequest() client.RawApiRequest {
|
||||
return client.RawApiRequest{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/test/v1/items",
|
||||
As: core.AsBot,
|
||||
}
|
||||
}
|
||||
|
||||
func assertServicePaginateJSONBytes(t *testing.T, got []byte, want interface{}) {
|
||||
t.Helper()
|
||||
wantBytes, err := json.MarshalIndent(want, "", " ")
|
||||
if err != nil {
|
||||
t.Fatalf("marshal expected JSON: %v", err)
|
||||
}
|
||||
wantBytes = append(wantBytes, '\n')
|
||||
if !bytes.Equal(got, wantBytes) {
|
||||
t.Fatalf("stdout bytes mismatch\ngot:\n%s\nwant:\n%s", got, wantBytes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_DefaultAggregatesAllPages(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
calls := 0
|
||||
wantTokens := []string{"", "next-1", "next-2"}
|
||||
for i, wantToken := range wantTokens {
|
||||
page := i + 1
|
||||
hasMore := page < len(wantTokens)
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": string(rune('0' + page))}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = wantTokens[page]
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(req *http.Request) {
|
||||
calls++
|
||||
if got := req.URL.Query().Get("page_token"); got != wantToken {
|
||||
t.Errorf("request %d page_token = %q, want %q", page, got, wantToken)
|
||||
}
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatJSON, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("servicePaginate() error = %v, want nil", err)
|
||||
}
|
||||
if calls != 3 {
|
||||
t.Fatalf("pagination requests = %d, want 3", calls)
|
||||
}
|
||||
assertServicePaginateJSONBytes(t, out.Bytes(), output.Envelope{
|
||||
OK: true,
|
||||
Identity: "bot",
|
||||
Data: map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1"},
|
||||
map[string]interface{}{"id": "2"},
|
||||
map[string]interface{}{"id": "3"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
})
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamingFormatsEmitExactMultiPageBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ndjson",
|
||||
format: output.FormatNDJSON,
|
||||
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Carol\",\"page_only\":\"ignored\"}\n",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
format: output.FormatTable,
|
||||
want: "id name \n── ─────\n1 Alice\n2 Carol\n",
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
format: output.FormatCSV,
|
||||
want: "id,name\n1,Alice\n2,Carol\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "next-1",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "2", "name": "Carol", "page_only": "ignored"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
tt.format, "", out, errOut, "lark-cli test items list", client.PaginationOptions{
|
||||
PageLimit: 10,
|
||||
PageDelay: -1,
|
||||
}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("servicePaginate() 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)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamingWriteFailureStopsFurtherPages(t *testing.T) {
|
||||
ac, _, errOut, reg := newServicePaginateTestHarness(t)
|
||||
sentinel := errors.New("page write failed")
|
||||
out := &serviceFailOnWriteWriter{failAt: 2, err: sentinel}
|
||||
calls := 0
|
||||
for page := 1; page <= 2; page++ {
|
||||
hasMore := true
|
||||
data := map[string]interface{}{
|
||||
"items": []interface{}{map[string]interface{}{"id": page}},
|
||||
"has_more": hasMore,
|
||||
}
|
||||
if hasMore {
|
||||
data["page_token"] = fmt.Sprintf("next-%d", page)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
OnMatch: func(*http.Request) {
|
||||
calls++
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": data,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageLimit: 10, PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("servicePaginate() 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)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("pagination requests = %d, want 2", calls)
|
||||
}
|
||||
if got, want := out.buf.String(), "{\"id\":1}\n"; got != want {
|
||||
t.Fatalf("stdout bytes = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamingFormatFallsBackToJSONWithoutList(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"name": "Test User",
|
||||
"user_id": "u123",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items get",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("servicePaginate() 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_BusinessErrorsWriteRawAndRemainUnmarked(t *testing.T) {
|
||||
businessResponse := map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{"detail": "business failed"},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "default_json", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: businessResponse,
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("servicePaginate() error = nil, want business error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
}
|
||||
assertServicePaginateJSONBytes(t, out.Bytes(), businessResponse)
|
||||
if bytes.Contains(out.Bytes(), []byte(`"ok": true`)) {
|
||||
t.Fatalf("business-error stdout contains a success envelope:\n%s", out.Bytes())
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_TransportErrorsRemainUnmarked(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
jqExpr string
|
||||
}{
|
||||
{name: "jq_paginate_all", format: output.FormatJSON, jqExpr: ".data.items"},
|
||||
{name: "stream_pages", format: output.FormatNDJSON},
|
||||
{name: "default_paginate_all", format: output.FormatJSON},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ac, out, errOut, _ := newServicePaginateTestHarness(t)
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
tt.format, tt.jqExpr, out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("servicePaginate() error = nil, want transport error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServicePaginate_StreamBusinessErrorRemainsUnmarked(t *testing.T) {
|
||||
ac, out, errOut, reg := newServicePaginateTestHarness(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 123456,
|
||||
"msg": "fixture business error",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
})
|
||||
|
||||
err := servicePaginate(context.Background(), ac, servicePaginateRequest(),
|
||||
output.FormatNDJSON, "", out, errOut, "lark-cli test items list",
|
||||
client.PaginationOptions{PageDelay: -1}, ac.CheckResponse)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("servicePaginate() error = nil, want business error")
|
||||
}
|
||||
if errs.IsRaw(err) {
|
||||
t.Fatalf("errs.IsRaw(error) = true, want current servicePaginate pass-through behavior")
|
||||
}
|
||||
if got := out.String(); got != "" {
|
||||
t.Fatalf("stdout bytes = %q, want empty", got)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
39
cmd/service/testmain_test.go
Normal file
39
cmd/service/testmain_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/registrytest"
|
||||
)
|
||||
|
||||
// TestMain isolates service command tests from the host machine: config (and
|
||||
// the registry cache under it) is redirected to a temp dir, then the registry
|
||||
// is seeded from the tracked fixture and initialized eagerly. Tests pass on a
|
||||
// clean checkout with no network, no `make fetch_meta`, and no user cache.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
|
||||
// m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmd-service-test-*")
|
||||
if err != nil {
|
||||
println("cmd/service test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
|
||||
println("cmd/service test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := registrytest.Seed(root); err != nil {
|
||||
println("cmd/service test setup: registrytest.Seed failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
code := m.Run()
|
||||
os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -12,11 +13,34 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
const startupBrandHelperEnv = "GO_TEST_STARTUP_BRAND_HELPER"
|
||||
|
||||
var _ = flag.String("startup-brand-helper", "", "internal startup brand test helper nonce")
|
||||
|
||||
func isStartupBrandHelper() bool {
|
||||
return startupBrandHelperEnabled(os.Getenv(startupBrandHelperEnv), startupBrandHelperNonce(os.Args))
|
||||
}
|
||||
|
||||
func startupBrandHelperEnabled(envNonce, argNonce string) bool {
|
||||
return envNonce != "" && envNonce == argNonce
|
||||
}
|
||||
|
||||
func startupBrandHelperNonce(args []string) string {
|
||||
const prefix = "-startup-brand-helper="
|
||||
for _, arg := range args {
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
return strings.TrimPrefix(arg, prefix)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestResolveStartupBrand_Precedence(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||
@@ -54,7 +78,7 @@ func TestResolveStartupBrand_Precedence(t *testing.T) {
|
||||
// sync.Once, so the brand must be injected before the first catalog access.
|
||||
// It runs in a subprocess because the registry is process-global.
|
||||
func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
|
||||
if os.Getenv("GO_TEST_STARTUP_BRAND_HELPER") == "1" {
|
||||
if isStartupBrandHelper() {
|
||||
// Helper: replicate Execute()'s build wiring with a lark config.
|
||||
buildInternal(
|
||||
context.Background(), cmdutil.InvocationContext{},
|
||||
@@ -71,9 +95,11 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
nonce := uuid.NewString()
|
||||
t.Setenv(startupBrandHelperEnv, nonce)
|
||||
cmd := exec.Command(os.Args[0], "-test.run", "TestStartupBrandReachesRegistry_RealStartupOrder")
|
||||
cmd.Args = append(cmd.Args, "-startup-brand-helper="+nonce)
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GO_TEST_STARTUP_BRAND_HELPER=1",
|
||||
"LARKSUITE_CLI_CONFIG_DIR="+tmp,
|
||||
"LARKSUITE_CLI_REMOTE_META=off", // no network during the subprocess build
|
||||
)
|
||||
@@ -85,3 +111,33 @@ func TestStartupBrandReachesRegistry_RealStartupOrder(t *testing.T) {
|
||||
t.Errorf("registry brand after real startup order = %s, want lark", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupBrandHelperRequiresMatchingCommandNonce(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
envNonce string
|
||||
argNonce string
|
||||
want bool
|
||||
}{
|
||||
{name: "neither set"},
|
||||
{name: "ambient environment only", envNonce: "ambient"},
|
||||
{name: "command argument only", argNonce: "command"},
|
||||
{name: "mismatch", envNonce: "ambient", argNonce: "command"},
|
||||
{name: "matching", envNonce: "nonce", argNonce: "nonce", want: true},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := startupBrandHelperEnabled(tt.envNonce, tt.argNonce); got != tt.want {
|
||||
t.Fatalf("startupBrandHelperEnabled() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartupBrandHelperNonce(t *testing.T) {
|
||||
if got := startupBrandHelperNonce([]string{"test", "-test.run", "brand"}); got != "" {
|
||||
t.Fatalf("startupBrandHelperNonce() = %q, want empty", got)
|
||||
}
|
||||
if got := startupBrandHelperNonce([]string{"test", "-startup-brand-helper=nonce"}); got != "nonce" {
|
||||
t.Fatalf("startupBrandHelperNonce() = %q, want nonce", got)
|
||||
}
|
||||
}
|
||||
|
||||
46
cmd/testmain_test.go
Normal file
46
cmd/testmain_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/registry/registrytest"
|
||||
)
|
||||
|
||||
// TestMain isolates command-tree tests from the host machine: config (and the
|
||||
// registry cache under it) is redirected to a temp dir, then the registry is
|
||||
// seeded from the tracked fixture and initialized eagerly. Tests pass on a
|
||||
// clean checkout with no network, no `make fetch_meta`, and no user cache.
|
||||
//
|
||||
// Note: os.Exit skips deferred functions, so cleanup runs explicitly after
|
||||
// m.Run before exiting.
|
||||
func TestMain(m *testing.M) {
|
||||
if isStartupBrandHelper() {
|
||||
// Re-exec helper subprocess (startup_brand_test.go): the parent test
|
||||
// already provides an isolated config dir and disables remote metadata,
|
||||
// and the helper must own the first registry Init to prove the startup
|
||||
// order — do not seed or eagerly initialize here.
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmd-test-*")
|
||||
if err != nil {
|
||||
println("cmd test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", root); err != nil {
|
||||
println("cmd test setup: Setenv failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := registrytest.Seed(root); err != nil {
|
||||
println("cmd test setup: registrytest.Seed failed:", err.Error())
|
||||
os.RemoveAll(root)
|
||||
os.Exit(2)
|
||||
}
|
||||
code := m.Run()
|
||||
os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
23
cmd/update/testmain_test.go
Normal file
23
cmd/update/testmain_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdupdate
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-update-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -24,6 +24,8 @@ import (
|
||||
"github.com/larksuite/cli/internal/skillscheck"
|
||||
)
|
||||
|
||||
const runLiveSkillsTestsEnv = "LARKSUITE_CLI_RUN_LIVE_SKILLS_TESTS"
|
||||
|
||||
// newTestFactory creates a test factory with minimal config.
|
||||
func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
@@ -31,13 +33,17 @@ func newTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffe
|
||||
return f, stdout, stderr
|
||||
}
|
||||
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult.
|
||||
// mockDetect sets up newUpdater to return an Updater with the given DetectResult
|
||||
// and fully mocked skills operations. Tests that only care about install-method
|
||||
// detection must never fall through to the real npx skills CLI.
|
||||
func mockDetect(t *testing.T, result selfupdate.DetectResult) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
@@ -104,6 +110,18 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
||||
}
|
||||
}
|
||||
|
||||
func mockSkillsSync(t *testing.T) {
|
||||
t.Helper()
|
||||
origNew := newUpdater
|
||||
newUpdater = func() *selfupdate.Updater {
|
||||
u := selfupdate.New()
|
||||
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||
return u
|
||||
}
|
||||
t.Cleanup(func() { newUpdater = origNew })
|
||||
}
|
||||
|
||||
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
@@ -228,6 +246,9 @@ func TestNormalizeVersion(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
mockSkillsSync(t)
|
||||
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
@@ -256,6 +277,9 @@ func TestUpdateAlreadyUpToDate_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
mockSkillsSync(t)
|
||||
|
||||
f, _, stderr := newTestFactory(t)
|
||||
|
||||
cmd := NewCmdUpdate(f)
|
||||
@@ -281,6 +305,7 @@ func TestUpdateAlreadyUpToDate_Human(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateManual_JSON(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, stdout, _ := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{"--json"})
|
||||
@@ -312,6 +337,7 @@ func TestUpdateManual_JSON(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUpdateManual_Human(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
f, _, stderr := newTestFactory(t)
|
||||
cmd := NewCmdUpdate(f)
|
||||
cmd.SetArgs([]string{})
|
||||
@@ -1161,6 +1187,7 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
}
|
||||
called := false
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
called = true
|
||||
return successfulSkillsCommand()(args...)
|
||||
@@ -1177,7 +1204,10 @@ func TestRunSkillsAndState_DedupForceBypass(t *testing.T) {
|
||||
|
||||
func TestRunSkillsAndState_SuccessWritesState(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
updater := &selfupdate.Updater{SkillsCommandOverride: successfulSkillsCommand()}
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: successfulSkillsCommand(),
|
||||
}
|
||||
got := runSkillsAndState(updater, newTestIO(), "1.0.21", false)
|
||||
if got == nil || got.Err != nil {
|
||||
t.Fatalf("runSkillsAndState() = %+v, want non-nil with nil Err", got)
|
||||
@@ -1197,6 +1227,7 @@ func TestRunSkillsAndState_FailureKeepsOldState(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updater := &selfupdate.Updater{
|
||||
SkillsIndexFetchOverride: successfulSkillsIndexFetch(),
|
||||
SkillsCommandOverride: func(args ...string) *selfupdate.NpmResult {
|
||||
r := &selfupdate.NpmResult{}
|
||||
r.Err = fmt.Errorf("npx failed")
|
||||
@@ -1513,28 +1544,133 @@ func TestEmitSkillsTextHints_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
|
||||
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
|
||||
// state file. It calls the real npx skills CLI, so the test is skipped when
|
||||
// npx or the skills registry is unavailable (e.g. no network or fork PRs).
|
||||
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skipf("npx not found in PATH: %v", err)
|
||||
// liveSkillsIsolationEnv is the single source of truth for the user-state
|
||||
// directories a live skills test must redirect under the temporary home. It
|
||||
// covers the CLI's own config, the agent homes the skills CLI installs into,
|
||||
// the XDG dirs it derives paths from (XDG_STATE_HOME holds its global
|
||||
// .skill-lock.json), and the npm/npx overrides that take precedence over
|
||||
// HOME-derived defaults (both cases: npm reads npm_config_* case-insensitively).
|
||||
func liveSkillsIsolationEnv(home string) map[string]string {
|
||||
return map[string]string{
|
||||
"HOME": home,
|
||||
"USERPROFILE": home,
|
||||
"APPDATA": filepath.Join(home, "AppData", "Roaming"),
|
||||
"LOCALAPPDATA": filepath.Join(home, "AppData", "Local"),
|
||||
"XDG_CONFIG_HOME": filepath.Join(home, ".config"),
|
||||
"XDG_DATA_HOME": filepath.Join(home, ".local", "share"),
|
||||
"XDG_STATE_HOME": filepath.Join(home, ".local", "state"),
|
||||
"CODEX_HOME": filepath.Join(home, ".codex"),
|
||||
"CLAUDE_CONFIG_DIR": filepath.Join(home, ".claude"),
|
||||
"LARKSUITE_CLI_CONFIG_DIR": filepath.Join(home, ".lark-cli"),
|
||||
"npm_config_cache": filepath.Join(home, ".npm-cache"),
|
||||
"NPM_CONFIG_CACHE": filepath.Join(home, ".npm-cache"),
|
||||
"npm_config_prefix": filepath.Join(home, ".npm-global"),
|
||||
"NPM_CONFIG_PREFIX": filepath.Join(home, ".npm-global"),
|
||||
"npm_config_userconfig": filepath.Join(home, ".npmrc"),
|
||||
"NPM_CONFIG_USERCONFIG": filepath.Join(home, ".npmrc"),
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
}
|
||||
|
||||
func prepareLiveSkillsIntegration(t *testing.T) string {
|
||||
t.Helper()
|
||||
if os.Getenv(runLiveSkillsTestsEnv) != "1" {
|
||||
t.Skipf("live skills integration test disabled; set %s=1 to run", runLiveSkillsTestsEnv)
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
for key, value := range liveSkillsIsolationEnv(home) {
|
||||
t.Setenv(key, value)
|
||||
}
|
||||
return home
|
||||
}
|
||||
|
||||
func TestPrepareLiveSkillsIntegration(t *testing.T) {
|
||||
reachedAfterGate := false
|
||||
t.Run("requires explicit opt-in", func(t *testing.T) {
|
||||
t.Setenv(runLiveSkillsTestsEnv, "")
|
||||
prepareLiveSkillsIntegration(t)
|
||||
reachedAfterGate = true
|
||||
})
|
||||
if reachedAfterGate {
|
||||
t.Fatal("prepareLiveSkillsIntegration continued without explicit opt-in")
|
||||
}
|
||||
|
||||
t.Run("isolates user directories", func(t *testing.T) {
|
||||
t.Setenv(runLiveSkillsTestsEnv, "1")
|
||||
home := prepareLiveSkillsIntegration(t)
|
||||
// Pin the isolation contract by key: removing a variable from
|
||||
// liveSkillsIsolationEnv must fail this list, and every redirected
|
||||
// value must live under the temporary home.
|
||||
required := []string{
|
||||
"HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA",
|
||||
"XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME",
|
||||
"CODEX_HOME", "CLAUDE_CONFIG_DIR", "LARKSUITE_CLI_CONFIG_DIR",
|
||||
"npm_config_cache", "NPM_CONFIG_CACHE",
|
||||
"npm_config_prefix", "NPM_CONFIG_PREFIX",
|
||||
"npm_config_userconfig", "NPM_CONFIG_USERCONFIG",
|
||||
}
|
||||
env := liveSkillsIsolationEnv(home)
|
||||
for _, key := range required {
|
||||
expected, ok := env[key]
|
||||
if !ok {
|
||||
t.Errorf("liveSkillsIsolationEnv dropped required key %s", key)
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(expected, home) {
|
||||
t.Errorf("%s = %q escapes temporary home %q", key, expected, home)
|
||||
}
|
||||
if got := os.Getenv(key); got != expected {
|
||||
t.Errorf("%s = %q, want %q", key, got, expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// seedLiveSkillsGlobal verifies the real npx skills CLI is reachable, installs
|
||||
// lark-calendar into the isolated global skills dir, and returns the parsed
|
||||
// global skills list. The caller opted in explicitly, so every missing
|
||||
// precondition is a hard failure — skipping would report "nothing verified"
|
||||
// as a green run.
|
||||
func seedLiveSkillsGlobal(t *testing.T) []string {
|
||||
t.Helper()
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Fatalf("live skills tests opted in but npx not found in PATH: %v", err)
|
||||
}
|
||||
// Three sequential npx runs against a cold cache (the isolated home starts
|
||||
// empty) can be slow; with Fatal-on-timeout semantics the budget errs on
|
||||
// the generous side.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
t.Fatalf("live skills tests opted in but real skills CLI unavailable: %v", err)
|
||||
}
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "-s", "lark-calendar", "-g", "-y").Run(); err != nil {
|
||||
t.Fatalf("failed to seed isolated global skills: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
t.Fatalf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
||||
if len(localSkills) == 0 {
|
||||
t.Fatal("seeded lark-calendar but global skills list is empty")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Fatalf("real skills CLI availability check timed out: %v", err)
|
||||
}
|
||||
return localSkills
|
||||
}
|
||||
|
||||
// TestUpdateCommand_RealSkillsSyncRewritesState is a live integration test that
|
||||
// verifies "lark-cli update" correctly triggers skills sync and rewrites the
|
||||
// state file. It calls the real npx skills CLI and only runs with explicit
|
||||
// opt-in. All user directories are redirected to a temporary home.
|
||||
func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
prepareLiveSkillsIntegration(t)
|
||||
|
||||
// Phase 1: Verify the real npx skills CLI is available and seed the
|
||||
// isolated global skills install.
|
||||
localSkills := seedLiveSkillsGlobal(t)
|
||||
|
||||
// Phase 2: Seed a previous sync state simulating an upgrade from v1.0.19.
|
||||
// lark-doc and lark-mail are recorded as skipped/deleted, meaning the user
|
||||
@@ -1630,26 +1766,17 @@ func TestUpdateCommand_RealSkillsSyncRewritesState(t *testing.T) {
|
||||
// not exist (cold start), the update command installs all official skills and
|
||||
// writes a fresh state file. No skill should appear in SkippedDeletedSkills
|
||||
// because there is no previous state to preserve user deletions from.
|
||||
// This is a live integration test that calls the real npx skills CLI; it is
|
||||
// skipped when npx or the skills registry is unavailable.
|
||||
// This is a live integration test that calls the real npx skills CLI and only
|
||||
// runs with explicit opt-in. All user directories are redirected to a temporary
|
||||
// home.
|
||||
func TestUpdateCommand_SkillsSyncColdStart(t *testing.T) {
|
||||
// Phase 1: Verify the real npx skills CLI is available; skip otherwise.
|
||||
if _, err := exec.LookPath("npx"); err != nil {
|
||||
t.Skipf("npx not found in PATH: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second)
|
||||
defer cancel()
|
||||
if err := exec.CommandContext(ctx, "npx", "-y", "skills", "add", "https://open.feishu.cn", "--list").Run(); err != nil {
|
||||
t.Skipf("real skills CLI unavailable: %v", err)
|
||||
}
|
||||
globalOut, err := exec.CommandContext(ctx, "npx", "-y", "skills", "ls", "-g").Output()
|
||||
if err != nil {
|
||||
t.Skipf("real global skills CLI unavailable: %v", err)
|
||||
}
|
||||
localSkills := skillscheck.ParseSkillsList(string(globalOut))
|
||||
if err := ctx.Err(); err != nil {
|
||||
t.Skipf("real skills CLI availability check timed out: %v", err)
|
||||
}
|
||||
prepareLiveSkillsIntegration(t)
|
||||
|
||||
// Phase 1: Verify the real npx skills CLI is available and seed one known
|
||||
// official skill into the isolated global install. Cold start means no
|
||||
// skills-state.json — locally installed skills may still exist, and seeding
|
||||
// one keeps the Phase 4 per-skill assertions from running zero times.
|
||||
localSkills := seedLiveSkillsGlobal(t)
|
||||
|
||||
// Phase 2: Use an isolated config dir with no pre-existing skills-state.json.
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||
|
||||
23
internal/auth/testmain_test.go
Normal file
23
internal/auth/testmain_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-internal-auth-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_LOG_DIR", filepath.Join(root, "logs")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -132,16 +132,14 @@ func HandleResponse(resp *larkcore.ApiResp, opts ResponseOptions) error {
|
||||
})
|
||||
}
|
||||
|
||||
// Content safety scanning for non-JSON presentation formats.
|
||||
scanResult := output.ScanForSafety(opts.CommandPath, result, opts.ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(opts.ErrOut, scanResult.Alert)
|
||||
}
|
||||
output.FormatValue(opts.Out, result, opts.Format)
|
||||
return nil
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: opts.Out,
|
||||
ErrOut: opts.ErrOut,
|
||||
CommandPath: opts.CommandPath,
|
||||
Identity: string(identity),
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
return emitter.Success(result, output.EmitOptions{Format: opts.Format.String()})
|
||||
}
|
||||
|
||||
// Non-JSON (binary) responses.
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/vfs/localfileio"
|
||||
)
|
||||
@@ -239,6 +240,87 @@ func TestHandleResponse_JSON(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_NonJSONFormatsEmitExactStructuredResponseBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
format output.Format
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "ndjson",
|
||||
format: output.FormatNDJSON,
|
||||
want: "{\"id\":\"1\",\"name\":\"Alice\"}\n{\"id\":\"2\",\"name\":\"Bob\"}\n",
|
||||
},
|
||||
{
|
||||
name: "table",
|
||||
format: output.FormatTable,
|
||||
want: "id name \n── ─────\n1 Alice\n2 Bob \n",
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
format: output.FormatCSV,
|
||||
want: "id,name\n1,Alice\n2,Bob\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
reg := &httpmock.Registry{}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: http.MethodGet,
|
||||
URL: "/open-apis/test/v1/items",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
map[string]interface{}{"id": "2", "name": "Bob"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
httpResp, err := httpmock.NewClient(reg).Get("https://open.feishu.cn/open-apis/test/v1/items")
|
||||
if err != nil {
|
||||
t.Fatalf("fixture request failed: %v", err)
|
||||
}
|
||||
body, err := io.ReadAll(httpResp.Body)
|
||||
_ = httpResp.Body.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("read fixture response: %v", err)
|
||||
}
|
||||
resp := &larkcore.ApiResp{
|
||||
StatusCode: httpResp.StatusCode,
|
||||
Header: httpResp.Header.Clone(),
|
||||
RawBody: body,
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
var errOut bytes.Buffer
|
||||
err = HandleResponse(resp, ResponseOptions{
|
||||
Format: tt.format,
|
||||
Identity: core.AsBot,
|
||||
Out: &out,
|
||||
ErrOut: &errOut,
|
||||
CommandPath: "lark-cli api GET",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HandleResponse() 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)
|
||||
}
|
||||
if got := errOut.String(); got != "" {
|
||||
t.Fatalf("stderr bytes = %q, want empty", got)
|
||||
}
|
||||
reg.Verify(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleResponse_JSONWithJqUsesSuccessEnvelope(t *testing.T) {
|
||||
body := []byte(`{"code":0,"msg":"ok","data":{"id":"1"}}`)
|
||||
resp := newApiResp(body, map[string]string{"Content-Type": "application/json"})
|
||||
|
||||
30
internal/cmdutil/testmain_test.go
Normal file
30
internal/cmdutil/testmain_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package cmdutil
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Default-factory tests initialize the registry and resolve config. Keep
|
||||
// them deterministic: never read the developer's real ~/.lark-cli and
|
||||
// prevent background remote-metadata refreshes from touching user state.
|
||||
root, err := os.MkdirTemp("", "lark-cli-cmdutil-test-*")
|
||||
if err != nil {
|
||||
println("internal/cmdutil test setup: MkdirTemp failed:", err.Error())
|
||||
os.Exit(2)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_REMOTE_META", "off"); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
23
internal/event/testmain_test.go
Normal file
23
internal/event/testmain_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package event
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-event-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
28
internal/keychain/testmain_test.go
Normal file
28
internal/keychain/testmain_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package keychain
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-keychain-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for key, value := range map[string]string{
|
||||
"LARKSUITE_CLI_DATA_DIR": filepath.Join(root, "data"),
|
||||
"LARKSUITE_CLI_LOG_DIR": filepath.Join(root, "logs"),
|
||||
} {
|
||||
if err := os.Setenv(key, value); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
code := m.Run()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -7,70 +7,91 @@ import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// FormatAsCSV formats data as CSV (with header) and writes it to w.
|
||||
func FormatAsCSV(w io.Writer, data interface{}) {
|
||||
FormatAsCSVPaginated(w, data, true)
|
||||
// Match the other legacy wrappers: surface only a marshal failure (as the
|
||||
// JSON fallback historically did); plain write failures stay swallowed.
|
||||
if err := WriteCSV(w, data); isOutputMarshalError(err) {
|
||||
legacyStderrf("json marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteCSV formats data as CSV and returns marshal or write errors.
|
||||
func WriteCSV(w io.Writer, data interface{}) error {
|
||||
return WriteCSVPaginated(w, data, true)
|
||||
}
|
||||
|
||||
// FormatAsCSVPaginated formats data as CSV with pagination awareness.
|
||||
// When isFirstPage is true, outputs the header row; otherwise only data rows.
|
||||
func FormatAsCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) {
|
||||
if err := WriteCSVPaginated(w, data, isFirstPage); isOutputMarshalError(err) {
|
||||
legacyStderrf("json marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteCSVPaginated formats data as CSV and returns marshal or write errors.
|
||||
func WriteCSVPaginated(w io.Writer, data interface{}, isFirstPage bool) error {
|
||||
rows, cols, isList := prepareRows(data)
|
||||
if cols == nil {
|
||||
if isList {
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
} else {
|
||||
PrintJson(w, data)
|
||||
return WriteJSON(w, data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
if isFirstPage {
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
}
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isList {
|
||||
// Single object: key,value rows
|
||||
cw := csv.NewWriter(w)
|
||||
if isFirstPage {
|
||||
cw.Write([]string{"key", "value"})
|
||||
if err := cw.Write([]string{"key", "value"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, col := range cols {
|
||||
cw.Write([]string{col, rows[0][col]})
|
||||
if err := cw.Write([]string{col, rows[0][col]}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
flushCSV(cw)
|
||||
return
|
||||
return flushCSV(cw)
|
||||
}
|
||||
|
||||
writeCSVRows(w, rows, cols, isFirstPage)
|
||||
return writeCSVRows(w, rows, cols, isFirstPage)
|
||||
}
|
||||
|
||||
// writeCSVRows writes CSV data rows (and optionally header) using the given columns.
|
||||
func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) {
|
||||
func writeCSVRows(w io.Writer, rows []map[string]string, cols []string, writeHeader bool) error {
|
||||
cw := csv.NewWriter(w)
|
||||
if writeHeader {
|
||||
cw.Write(cols)
|
||||
if err := cw.Write(cols); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
record := make([]string, len(cols))
|
||||
for i, col := range cols {
|
||||
record[i] = row[col]
|
||||
}
|
||||
cw.Write(record)
|
||||
if err := cw.Write(record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
flushCSV(cw)
|
||||
return flushCSV(cw)
|
||||
}
|
||||
|
||||
// flushCSV flushes the csv.Writer and reports any write error to stderr.
|
||||
func flushCSV(cw *csv.Writer) {
|
||||
// flushCSV flushes the csv.Writer and returns any write error.
|
||||
func flushCSV(cw *csv.Writer) error {
|
||||
cw.Flush()
|
||||
if err := cw.Error(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "csv write error: %v\n", err)
|
||||
}
|
||||
return cw.Error()
|
||||
}
|
||||
|
||||
@@ -50,10 +50,11 @@ func wrapBlockError(alert *extcs.Alert) error {
|
||||
|
||||
// WriteAlertWarning writes a human-readable content-safety warning to w.
|
||||
// Used by non-JSON output paths (pretty, table, csv) in warn mode.
|
||||
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) {
|
||||
func WriteAlertWarning(w io.Writer, alert *extcs.Alert) error {
|
||||
if alert == nil {
|
||||
return
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
|
||||
_, err := fmt.Fprintf(w, "warning: content safety alert from %s (rules: %s)\n",
|
||||
alert.Provider, strings.Join(alert.MatchedRules, ", "))
|
||||
return err
|
||||
}
|
||||
|
||||
336
internal/output/emitter.go
Normal file
336
internal/output/emitter.go
Normal file
@@ -0,0 +1,336 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"maps"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
// NoticeProvider supplies the notice attached to a structured envelope.
|
||||
// The provider is captured by an Emitter so emission never reads the global
|
||||
// PendingNotice hook implicitly.
|
||||
type NoticeProvider func() map[string]interface{}
|
||||
|
||||
// PrettyRenderer writes the human-readable representation of one result.
|
||||
// colorEnabled is the terminal capability captured when the Emitter is built.
|
||||
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
|
||||
}
|
||||
|
||||
// EmitOptions describes one result's wire representation.
|
||||
//
|
||||
// The format contract is explicit: JSON (including the empty default) 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.
|
||||
type EmitOptions struct {
|
||||
Raw bool
|
||||
Meta *Meta
|
||||
Format string
|
||||
JQ string
|
||||
DryRun bool
|
||||
Pretty PrettyRenderer
|
||||
JQSafetyWarning bool
|
||||
}
|
||||
|
||||
// StreamOptions describes one streamed page's wire representation. Streaming
|
||||
// carries page items directly, so it deliberately exposes only the fields that
|
||||
// affect a single page: the format and, for pretty, its renderer. It has no
|
||||
// OK/Meta/DryRun/JQ — an ok:false envelope, metadata, dry-run, and jq all need
|
||||
// the aggregated result, which the caller's pagination layer owns before it
|
||||
// streams pages.
|
||||
type StreamOptions struct {
|
||||
Format string
|
||||
Pretty PrettyRenderer
|
||||
}
|
||||
|
||||
// Emitter owns all command-scoped output dependencies and pagination state.
|
||||
// It deliberately has no dependency on client or cmdutil.
|
||||
type Emitter struct {
|
||||
out io.Writer
|
||||
errOut io.Writer
|
||||
commandPath string
|
||||
identity string
|
||||
colorEnabled bool
|
||||
noticeProvider NoticeProvider
|
||||
|
||||
streamFormat string
|
||||
streamFormatter *PaginatedFormatter
|
||||
}
|
||||
|
||||
// NewEmitter constructs a command-scoped output emitter.
|
||||
func NewEmitter(config EmitterConfig) *Emitter {
|
||||
errOut := config.ErrOut
|
||||
if errOut == nil {
|
||||
errOut = io.Discard
|
||||
}
|
||||
return &Emitter{
|
||||
out: config.Out,
|
||||
errOut: errOut,
|
||||
commandPath: config.CommandPath,
|
||||
identity: config.Identity,
|
||||
colorEnabled: config.ColorEnabled,
|
||||
noticeProvider: config.NoticeProvider,
|
||||
}
|
||||
}
|
||||
|
||||
// Success scans and emits one command result by composing the package's leaf
|
||||
// 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 err := e.requireOutput(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.JQ != "" {
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
}
|
||||
|
||||
switch opts.Format {
|
||||
case "", "json":
|
||||
return e.emitEnvelope(data, true, opts)
|
||||
case "pretty":
|
||||
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 {
|
||||
if err := e.requireOutput(); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.emitEnvelope(data, false, opts)
|
||||
}
|
||||
|
||||
// StreamPage scans and emits one page while retaining table/csv columns from
|
||||
// the first page. Streamed output carries page items directly, so it takes a
|
||||
// StreamOptions (format + optional pretty renderer) rather than the full
|
||||
// EmitOptions: ok/meta/dry-run/jq all need the aggregated result and are the
|
||||
// caller's pagination-layer responsibility, not a per-page concern. Excluding
|
||||
// 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 err := e.requireOutput(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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.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 {
|
||||
e.streamFormat = opts.Format
|
||||
e.streamFormatter = NewPaginatedFormatter(nil, format)
|
||||
} 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)
|
||||
})
|
||||
}
|
||||
|
||||
func (e *Emitter) emitEnvelope(data interface{}, ok bool, opts EmitOptions) error {
|
||||
scanResult := ScanForSafety(e.commandPath, data, e.errOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
|
||||
env := Envelope{
|
||||
OK: ok,
|
||||
Identity: e.identity,
|
||||
DryRun: opts.DryRun,
|
||||
Data: data,
|
||||
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)
|
||||
}
|
||||
}
|
||||
// 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
|
||||
// returned unchanged; only a genuine stdout write failure is wrapped as
|
||||
// an internal output error.
|
||||
var buf bytes.Buffer
|
||||
var jqErr error
|
||||
if opts.Raw {
|
||||
jqErr = JqFilterRaw(&buf, env, opts.JQ)
|
||||
} else {
|
||||
jqErr = JqFilter(&buf, env, opts.JQ)
|
||||
}
|
||||
if jqErr != nil {
|
||||
return jqErr
|
||||
}
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return wrapOutputError("render", err)
|
||||
}
|
||||
if _, err := io.Copy(e.out, &buf); err != nil {
|
||||
return wrapOutputError("write", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wrapOutputError(op string, err error) error {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown, "failed to %s command output", op).WithCause(err)
|
||||
}
|
||||
|
||||
func (e *Emitter) notice() map[string]interface{} {
|
||||
if e.noticeProvider == nil {
|
||||
return nil
|
||||
}
|
||||
return e.noticeProvider()
|
||||
}
|
||||
|
||||
func (e *Emitter) requireOutput() error {
|
||||
if e == nil || e.out == nil {
|
||||
return errs.NewInternalError(errs.SubtypeUnknown,
|
||||
"success output writer is not configured")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
350
internal/output/emitter_contract_test.go
Normal file
350
internal/output/emitter_contract_test.go
Normal file
@@ -0,0 +1,350 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package output_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
)
|
||||
|
||||
type contractFailingWriter struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (w contractFailingWriter) Write([]byte) (int, error) {
|
||||
return 0, w.err
|
||||
}
|
||||
|
||||
type contractSafetyProvider struct {
|
||||
alert *extcs.Alert
|
||||
}
|
||||
|
||||
func (p *contractSafetyProvider) Name() string {
|
||||
return "emitter-contract"
|
||||
}
|
||||
|
||||
func (p *contractSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.alert, nil
|
||||
}
|
||||
|
||||
func TestEmitterSuccessWritesAllBytes(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
Identity: "bot",
|
||||
})
|
||||
data := map[string]interface{}{"id": "1"}
|
||||
|
||||
err := emitter.Success(data, output.EmitOptions{Format: "json"})
|
||||
if err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
want, marshalErr := json.MarshalIndent(output.Envelope{OK: true, Identity: "bot", Data: data}, "", " ")
|
||||
if marshalErr != nil {
|
||||
t.Fatalf("marshal expected envelope: %v", marshalErr)
|
||||
}
|
||||
want = append(want, '\n')
|
||||
if !bytes.Equal(stdout.Bytes(), want) {
|
||||
t.Fatalf("stdout bytes = %q, want %q", stdout.Bytes(), want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterMarshalFailureReturnsTypedErrorWithoutOutput(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
|
||||
err := emitter.Success(map[string]interface{}{"unsupported": func() {}}, output.EmitOptions{Format: "json"})
|
||||
if err == nil {
|
||||
t.Fatal("Emitter.Success() error = nil, want marshal failure")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
var unsupported *json.UnsupportedTypeError
|
||||
if !errors.As(err, &unsupported) {
|
||||
t.Fatalf("Emitter.Success() error = %v, want json.UnsupportedTypeError cause", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterWriterFailurePreservesCause(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
sentinel := errors.New("write failed")
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: contractFailingWriter{err: sentinel},
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
|
||||
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterPrettyRendererFailurePreservesCause(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
sentinel := errors.New("pretty render failed")
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
|
||||
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
|
||||
Format: "pretty",
|
||||
Pretty: func(io.Writer, bool) error {
|
||||
return sentinel
|
||||
},
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("Emitter.Success() error = %v, want preserved renderer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterAlertWarningFailurePreservesCause(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&contractSafetyProvider{alert: &extcs.Alert{
|
||||
Provider: "emitter-contract",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
}})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
sentinel := errors.New("warning write failed")
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: contractFailingWriter{err: sentinel},
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
|
||||
err := emitter.Success([]interface{}{map[string]interface{}{"id": "1"}}, output.EmitOptions{Format: "table"})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("Emitter.Success() error = %v, want preserved warning writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("Emitter.Success() stdout = %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEmitterDefaultsNilErrOutToDiscard(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "warn")
|
||||
extcs.Register(&contractSafetyProvider{alert: &extcs.Alert{
|
||||
Provider: "emitter-contract",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
}})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
|
||||
if err := emitter.Success([]interface{}{map[string]interface{}{"id": "1"}}, output.EmitOptions{Format: "table"}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
t.Fatal("Emitter.Success() stdout is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterDoesNotMutateCallerMap(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
data := map[string]interface{}{"ok": true, "value": "fixture"}
|
||||
want := map[string]interface{}{"ok": true, "value": "fixture"}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
NoticeProvider: func() map[string]interface{} {
|
||||
return map[string]interface{}{"update": "available"}
|
||||
},
|
||||
})
|
||||
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: "yaml"}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(data, want) {
|
||||
t.Fatalf("caller map = %#v, want unchanged %#v", data, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterDoesNotOverwriteCallerNotice(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
existing := map[string]interface{}{"source": "caller"}
|
||||
data := map[string]interface{}{"ok": true, "_notice": existing}
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
NoticeProvider: func() map[string]interface{} {
|
||||
return map[string]interface{}{"source": "provider"}
|
||||
},
|
||||
})
|
||||
|
||||
if err := emitter.Success(data, output.EmitOptions{Format: "yaml"}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
if got := data["_notice"]; !reflect.DeepEqual(got, existing) {
|
||||
t.Fatalf("caller _notice = %#v, want unchanged %#v", got, existing)
|
||||
}
|
||||
var emitted map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &emitted); err != nil {
|
||||
t.Fatalf("decode stdout: %v", err)
|
||||
}
|
||||
if got := emitted["_notice"]; !reflect.DeepEqual(got, map[string]interface{}{"source": "provider"}) {
|
||||
t.Fatalf("emitted _notice = %#v, want provider notice", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterReadsNoticeProviderAtMostOncePerEmission(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
calls := 0
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
NoticeProvider: func() map[string]interface{} {
|
||||
calls++
|
||||
return map[string]interface{}{"source": "provider"}
|
||||
},
|
||||
})
|
||||
|
||||
if err := emitter.Success(map[string]interface{}{"ok": true}, output.EmitOptions{Format: "yaml"}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("notice provider calls = %d, want 1", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterRawJSONPropagatesWriteError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
sentinel := errors.New("write failed")
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: contractFailingWriter{err: sentinel},
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
|
||||
Raw: true, Format: "json",
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterInvalidJQReturnsErrorWithoutStderr(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: &bytes.Buffer{},
|
||||
ErrOut: stderr,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
|
||||
Format: "json",
|
||||
JQ: "this is not valid jq (((",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Success() with invalid jq = nil, want error")
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("Success() with invalid jq wrote stderr %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterJQRuntimeErrorPreservesTypedError(t *testing.T) {
|
||||
// A valid expression that fails at runtime must surface jq's own typed error
|
||||
// (an api error), not a wrapped internal output error, and must emit no
|
||||
// partial stdout.
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
|
||||
Format: "json",
|
||||
JQ: `error("boom")`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Success() with a runtime jq error = nil, want error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category == errs.CategoryInternal {
|
||||
t.Fatalf("Success() jq runtime error problem = %#v, %v; want jq's own typed error, not internal", problem, ok)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "jq error") {
|
||||
t.Fatalf("Success() jq runtime error = %v, want jq's own error message preserved", err)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("Success() jq runtime error wrote stdout %q, want empty", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterUnknownFormatStructKeepsNotice(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
type payload struct {
|
||||
OK bool `json:"ok"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
stdout := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
NoticeProvider: func() map[string]interface{} {
|
||||
return map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}}
|
||||
},
|
||||
})
|
||||
if err := emitter.Success(payload{OK: true, Value: "fixture"}, output.EmitOptions{Format: "yaml"}); err != nil {
|
||||
t.Fatalf("Success() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stdout.String(), "_notice") {
|
||||
t.Fatalf("struct payload on unknown-format fallback dropped _notice:\n%s", stdout.String())
|
||||
}
|
||||
}
|
||||
827
internal/output/emitter_legacy_compat_test.go
Normal file
827
internal/output/emitter_legacy_compat_test.go
Normal file
@@ -0,0 +1,827 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Legacy oracle fixtures are frozen at base SHA 4a56748bfa941ff0ee0bfec92e65acac427732b0.
|
||||
// Golden regeneration is allowed only from that base, never from the current system under test.
|
||||
|
||||
package output_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"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/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type emitterCapture struct {
|
||||
stdout string
|
||||
stderr string
|
||||
err error
|
||||
}
|
||||
|
||||
type emitterSafetyProvider struct {
|
||||
alert *extcs.Alert
|
||||
err error
|
||||
}
|
||||
|
||||
func (p *emitterSafetyProvider) Name() string { return "emitter-oracle" }
|
||||
|
||||
func (p *emitterSafetyProvider) Scan(context.Context, extcs.ScanRequest) (*extcs.Alert, error) {
|
||||
return p.alert, p.err
|
||||
}
|
||||
|
||||
const (
|
||||
runtimeContextLegacyGoldenPath = "testdata/runtime_context_legacy.golden.json"
|
||||
writeSuccessEnvelopeLegacyGoldenPath = "testdata/write_success_envelope_legacy.golden.json"
|
||||
)
|
||||
|
||||
type runtimeContextOracleCase struct {
|
||||
name string
|
||||
data func() interface{}
|
||||
raw bool
|
||||
ok bool
|
||||
meta *output.Meta
|
||||
jq string
|
||||
format string
|
||||
useFormat bool
|
||||
pretty bool
|
||||
notice map[string]interface{}
|
||||
safetyMode string
|
||||
safetyAlert *extcs.Alert
|
||||
safetyErr error
|
||||
}
|
||||
|
||||
type runtimeContextLegacyGolden struct {
|
||||
Cases map[string]emitterCaptureGolden `json:"cases"`
|
||||
}
|
||||
|
||||
type writeSuccessEnvelopeOracleCase struct {
|
||||
name string
|
||||
data func() interface{}
|
||||
dryRun bool
|
||||
jq string
|
||||
notice map[string]interface{}
|
||||
safetyMode string
|
||||
safetyAlert *extcs.Alert
|
||||
}
|
||||
|
||||
type writeSuccessEnvelopeLegacyGolden struct {
|
||||
Cases map[string]emitterCaptureGolden `json:"cases"`
|
||||
}
|
||||
|
||||
type emitterCaptureGolden struct {
|
||||
Stdout string `json:"stdout"`
|
||||
Stderr string `json:"stderr"`
|
||||
Error *emitterErrorGolden `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type emitterErrorGolden struct {
|
||||
GoType string `json:"go_type"`
|
||||
JSON json.RawMessage `json:"json"`
|
||||
Message string `json:"message"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
}
|
||||
|
||||
func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
|
||||
previousNotice := output.PendingNotice
|
||||
t.Cleanup(func() {
|
||||
output.PendingNotice = previousNotice
|
||||
extcs.Register(nil)
|
||||
})
|
||||
|
||||
cases := []runtimeContextOracleCase{
|
||||
{
|
||||
name: "json_object",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "1", "enabled": true}
|
||||
},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "raw_json_preserves_html",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"html": "<p>a&b</p>"}
|
||||
},
|
||||
raw: true,
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
name: "format_raw_json_preserves_html",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"html": "<p>a&b</p>"}
|
||||
},
|
||||
raw: true,
|
||||
ok: true,
|
||||
format: "json",
|
||||
useFormat: true,
|
||||
},
|
||||
{
|
||||
name: "partial_failure_ok_false",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"succeeded": 1, "failed": 1}
|
||||
},
|
||||
ok: false,
|
||||
},
|
||||
{
|
||||
name: "metadata",
|
||||
data: func() interface{} {
|
||||
return []interface{}{map[string]interface{}{"id": "1"}}
|
||||
},
|
||||
ok: true,
|
||||
meta: &output.Meta{Count: 1, Rollback: "lark-cli fixture rollback"},
|
||||
},
|
||||
{
|
||||
name: "jq_scalar",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"name": "Alice", "age": 30}
|
||||
},
|
||||
ok: true,
|
||||
jq: ".data.name",
|
||||
},
|
||||
{
|
||||
name: "raw_jq_complex",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"document": map[string]interface{}{"html": "<p>a&b</p>"}}
|
||||
},
|
||||
raw: true,
|
||||
ok: true,
|
||||
jq: ".data.document",
|
||||
},
|
||||
{
|
||||
name: "jq_invalid_expression",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "1"}
|
||||
},
|
||||
ok: false,
|
||||
jq: "invalid[",
|
||||
},
|
||||
{
|
||||
name: "notice",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "1"}
|
||||
},
|
||||
ok: true,
|
||||
notice: map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}},
|
||||
},
|
||||
{
|
||||
name: "pretty",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"name": "Alice"}
|
||||
},
|
||||
ok: true,
|
||||
format: "pretty",
|
||||
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{} {
|
||||
return map[string]interface{}{"items": []interface{}{
|
||||
map[string]interface{}{"id": "1"},
|
||||
map[string]interface{}{"id": "2"},
|
||||
}}
|
||||
},
|
||||
ok: true,
|
||||
format: "ndjson",
|
||||
useFormat: true,
|
||||
},
|
||||
{
|
||||
name: "table_with_safety_warning",
|
||||
data: func() interface{} {
|
||||
return []interface{}{map[string]interface{}{"id": "1", "name": "Alice"}}
|
||||
},
|
||||
ok: true,
|
||||
format: "table",
|
||||
useFormat: true,
|
||||
safetyMode: "warn",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "csv",
|
||||
data: func() interface{} {
|
||||
return []interface{}{
|
||||
map[string]interface{}{"id": "1", "name": "Alice"},
|
||||
map[string]interface{}{"id": "2", "name": "Bob"},
|
||||
}
|
||||
},
|
||||
ok: true,
|
||||
format: "csv",
|
||||
useFormat: true,
|
||||
},
|
||||
{
|
||||
name: "jq_safety_alert_without_stderr_warning",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "1"}
|
||||
},
|
||||
ok: true,
|
||||
jq: ".data.id",
|
||||
safetyMode: "warn",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scanner_error_fails_open",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "1"}
|
||||
},
|
||||
ok: true,
|
||||
safetyMode: "warn",
|
||||
safetyErr: errors.New("scanner unavailable"),
|
||||
},
|
||||
{
|
||||
name: "scanner_block",
|
||||
data: func() interface{} {
|
||||
return map[string]interface{}{"id": "blocked"}
|
||||
},
|
||||
ok: false,
|
||||
safetyMode: "block",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
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)
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mode := tc.safetyMode
|
||||
if mode == "" {
|
||||
mode = "off"
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
|
||||
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert, err: tc.safetyErr})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
notice := tc.notice
|
||||
output.PendingNotice = func() map[string]interface{} { return notice }
|
||||
|
||||
want, ok := golden.Cases[tc.name]
|
||||
if !ok {
|
||||
t.Fatalf("frozen golden case %q is missing", tc.name)
|
||||
}
|
||||
|
||||
opts := runtimeOracleOptions{
|
||||
raw: tc.raw,
|
||||
ok: tc.ok,
|
||||
meta: tc.meta,
|
||||
jq: tc.jq,
|
||||
format: tc.format,
|
||||
useFormat: tc.useFormat,
|
||||
pretty: tc.pretty,
|
||||
}
|
||||
current := runEmitterWithRuntimeContextContract(tc.data(), output.EmitterConfig{
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
Identity: "bot",
|
||||
NoticeProvider: func() map[string]interface{} { return notice },
|
||||
}, tc.ok, output.EmitOptions{
|
||||
Raw: tc.raw,
|
||||
Meta: tc.meta,
|
||||
Format: tc.format,
|
||||
JQ: tc.jq,
|
||||
Pretty: emitterPrettyRenderer(tc.pretty),
|
||||
})
|
||||
|
||||
assertEmitterGolden(t, want, current)
|
||||
|
||||
integrated := runRuntimeContextOracle(t, tc.data(), opts)
|
||||
assertEmitterGolden(t, want, integrated)
|
||||
if tc.safetyMode == "block" {
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(current.err, &safetyErr) {
|
||||
t.Fatalf("Emitter.Success() error = %T, want *errs.ContentSafetyError", current.err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if len(golden.Cases) != len(cases) {
|
||||
t.Fatalf("golden case count = %d, want %d", len(golden.Cases), len(cases))
|
||||
}
|
||||
|
||||
jqFailure := golden.Cases["jq_invalid_expression"]
|
||||
if !strings.HasPrefix(jqFailure.Stderr, "error: ") || !strings.HasSuffix(jqFailure.Stderr, "\n") {
|
||||
t.Fatalf("invalid jq golden stderr = %q, want error line ending in newline", jqFailure.Stderr)
|
||||
}
|
||||
if jqFailure.Error == nil || jqFailure.Error.ExitCode != output.ExitValidation {
|
||||
t.Fatalf("invalid jq golden exit = %#v, want %d", jqFailure.Error, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
func loadRuntimeContextLegacyGolden(t *testing.T) runtimeContextLegacyGolden {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(runtimeContextLegacyGoldenPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read RuntimeContext legacy golden: %v", err)
|
||||
}
|
||||
var golden runtimeContextLegacyGolden
|
||||
if err := json.Unmarshal(contents, &golden); err != nil {
|
||||
t.Fatalf("decode RuntimeContext legacy golden: %v", err)
|
||||
}
|
||||
return golden
|
||||
}
|
||||
|
||||
func captureEmitterGolden(t *testing.T, capture emitterCapture) emitterCaptureGolden {
|
||||
t.Helper()
|
||||
golden := emitterCaptureGolden{Stdout: capture.stdout, Stderr: capture.stderr}
|
||||
if capture.err == nil {
|
||||
return golden
|
||||
}
|
||||
errorJSON, err := json.Marshal(capture.err)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal captured error %T: %v", capture.err, err)
|
||||
}
|
||||
golden.Error = &emitterErrorGolden{
|
||||
GoType: fmt.Sprintf("%T", capture.err),
|
||||
JSON: errorJSON,
|
||||
Message: capture.err.Error(),
|
||||
ExitCode: output.ExitCodeOf(capture.err),
|
||||
}
|
||||
return golden
|
||||
}
|
||||
|
||||
type runtimeOracleOptions struct {
|
||||
raw bool
|
||||
ok bool
|
||||
meta *output.Meta
|
||||
jq string
|
||||
format string
|
||||
useFormat bool
|
||||
pretty bool
|
||||
}
|
||||
|
||||
func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
|
||||
t.Helper()
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
parent := &cobra.Command{Use: "lark-cli"}
|
||||
cmd := &cobra.Command{Use: "fixture"}
|
||||
leaf := &cobra.Command{Use: "+emit"}
|
||||
parent.AddCommand(cmd)
|
||||
cmd.AddCommand(leaf)
|
||||
|
||||
factory := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}}
|
||||
runtime := common.TestNewRuntimeContextForAPI(
|
||||
context.Background(), leaf, &core.CliConfig{Brand: core.BrandFeishu}, factory, core.AsBot,
|
||||
)
|
||||
runtime.Format = opts.format
|
||||
runtime.JqExpr = opts.jq
|
||||
|
||||
pretty := func(w io.Writer) {
|
||||
fmt.Fprintln(w, "pretty:fixture")
|
||||
}
|
||||
if !opts.pretty {
|
||||
pretty = nil
|
||||
}
|
||||
|
||||
var err error
|
||||
switch {
|
||||
case opts.useFormat && opts.raw:
|
||||
runtime.OutFormatRaw(data, opts.meta, pretty)
|
||||
case opts.useFormat:
|
||||
runtime.OutFormat(data, opts.meta, pretty)
|
||||
case !opts.ok:
|
||||
err = runtime.OutPartialFailure(data, opts.meta)
|
||||
case opts.raw:
|
||||
runtime.OutRaw(data, opts.meta)
|
||||
default:
|
||||
runtime.Out(data, opts.meta)
|
||||
}
|
||||
|
||||
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{}
|
||||
config.Out = stdout
|
||||
config.ErrOut = stderr
|
||||
emitter := output.NewEmitter(config)
|
||||
var err error
|
||||
if ok {
|
||||
err = emitter.Success(data, opts)
|
||||
} else {
|
||||
err = emitter.PartialFailure(data, opts)
|
||||
}
|
||||
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
|
||||
}
|
||||
|
||||
func runEmitterWithRuntimeContextContract(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
|
||||
capture := runEmitterSuccess(data, config, ok, opts)
|
||||
if capture.err != nil {
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if errors.As(capture.err, &safetyErr) {
|
||||
return capture
|
||||
}
|
||||
if opts.JQ != "" {
|
||||
capture.stderr += fmt.Sprintf("error: %v\n", capture.err)
|
||||
return capture
|
||||
}
|
||||
capture.err = nil
|
||||
}
|
||||
if !ok {
|
||||
capture.err = output.PartialFailure(output.ExitAPI)
|
||||
}
|
||||
return capture
|
||||
}
|
||||
|
||||
func emitterPrettyRenderer(enabled bool) output.PrettyRenderer {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
return func(w io.Writer, _ bool) error {
|
||||
_, err := fmt.Fprintln(w, "pretty:fixture")
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitterMatchesWriteSuccessEnvelopeLegacyOracle(t *testing.T) {
|
||||
previousNotice := output.PendingNotice
|
||||
t.Cleanup(func() {
|
||||
output.PendingNotice = previousNotice
|
||||
extcs.Register(nil)
|
||||
})
|
||||
|
||||
cases := []writeSuccessEnvelopeOracleCase{
|
||||
{
|
||||
name: "json",
|
||||
data: func() interface{} { return map[string]interface{}{"id": "1"} },
|
||||
},
|
||||
{
|
||||
name: "dry_run",
|
||||
data: func() interface{} { return map[string]interface{}{"api": []interface{}{}} },
|
||||
dryRun: true,
|
||||
},
|
||||
{
|
||||
name: "jq",
|
||||
data: func() interface{} { return map[string]interface{}{"id": "1"} },
|
||||
jq: ".data.id",
|
||||
},
|
||||
{
|
||||
name: "notice",
|
||||
data: func() interface{} { return map[string]interface{}{"id": "1"} },
|
||||
notice: map[string]interface{}{"update": map[string]interface{}{"latest": "9.9.9"}},
|
||||
},
|
||||
{
|
||||
name: "jq_safety_warning",
|
||||
data: func() interface{} { return map[string]interface{}{"id": "1"} },
|
||||
jq: ".data.id",
|
||||
safetyMode: "warn",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "scanner_block",
|
||||
data: func() interface{} { return map[string]interface{}{"id": "blocked"} },
|
||||
safetyMode: "block",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
}
|
||||
golden := loadWriteSuccessEnvelopeLegacyGolden(t)
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mode := tc.safetyMode
|
||||
if mode == "" {
|
||||
mode = "off"
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
|
||||
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
notice := tc.notice
|
||||
output.PendingNotice = func() map[string]interface{} { return notice }
|
||||
|
||||
want, ok := golden.Cases[tc.name]
|
||||
if !ok {
|
||||
t.Fatalf("frozen golden case %q is missing", tc.name)
|
||||
}
|
||||
|
||||
current := runEmitterSuccess(tc.data(), output.EmitterConfig{
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
Identity: "bot",
|
||||
NoticeProvider: func() map[string]interface{} { return notice },
|
||||
}, true, output.EmitOptions{
|
||||
Format: "",
|
||||
Raw: false,
|
||||
JQ: tc.jq,
|
||||
DryRun: tc.dryRun,
|
||||
JQSafetyWarning: true,
|
||||
})
|
||||
assertEmitterGolden(t, want, current)
|
||||
|
||||
integrated := runWriteSuccessEnvelopeOracle(tc.data(), tc.dryRun, tc.jq)
|
||||
assertEmitterGolden(t, want, integrated)
|
||||
})
|
||||
}
|
||||
|
||||
if len(golden.Cases) != len(cases) {
|
||||
t.Fatalf("golden case count = %d, want %d", len(golden.Cases), len(cases))
|
||||
}
|
||||
}
|
||||
|
||||
func loadWriteSuccessEnvelopeLegacyGolden(t *testing.T) writeSuccessEnvelopeLegacyGolden {
|
||||
t.Helper()
|
||||
contents, err := os.ReadFile(writeSuccessEnvelopeLegacyGoldenPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read WriteSuccessEnvelope legacy golden: %v", err)
|
||||
}
|
||||
var golden writeSuccessEnvelopeLegacyGolden
|
||||
if err := json.Unmarshal(contents, &golden); err != nil {
|
||||
t.Fatalf("decode WriteSuccessEnvelope legacy golden: %v", err)
|
||||
}
|
||||
return golden
|
||||
}
|
||||
|
||||
func runWriteSuccessEnvelopeOracle(data interface{}, dryRun bool, jq string) emitterCapture {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
err := output.WriteSuccessEnvelope(data, output.SuccessEnvelopeOptions{
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
Identity: "bot",
|
||||
DryRun: dryRun,
|
||||
JqExpr: jq,
|
||||
Out: stdout,
|
||||
ErrOut: stderr,
|
||||
})
|
||||
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
|
||||
}
|
||||
|
||||
func TestEmitterStreamPageMatchesPaginationLegacyOracle(t *testing.T) {
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
type oracleCase struct {
|
||||
name string
|
||||
format output.Format
|
||||
safetyMode string
|
||||
safetyAlert *extcs.Alert
|
||||
}
|
||||
cases := []oracleCase{
|
||||
{name: "ndjson", format: output.FormatNDJSON},
|
||||
{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",
|
||||
format: output.FormatTable,
|
||||
safetyMode: "block",
|
||||
safetyAlert: &extcs.Alert{
|
||||
Provider: "emitter-oracle",
|
||||
MatchedRules: []string{"fixture-rule"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
pages := []interface{}{
|
||||
[]interface{}{map[string]interface{}{"id": "1", "name": "Alice"}},
|
||||
[]interface{}{map[string]interface{}{"id": "2", "name": "Bob", "ignored": true}},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
mode := tc.safetyMode
|
||||
if mode == "" {
|
||||
mode = "off"
|
||||
}
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", mode)
|
||||
extcs.Register(&emitterSafetyProvider{alert: tc.safetyAlert})
|
||||
t.Cleanup(func() { extcs.Register(nil) })
|
||||
|
||||
legacy := runPaginationOracle(pages, tc.format)
|
||||
current := runEmitterStreamPages(pages, tc.format.String())
|
||||
|
||||
assertEmitterBytes(t, legacy, current)
|
||||
assertEquivalentError(t, legacy.err, current.err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func runPaginationOracle(pages []interface{}, format output.Format) emitterCapture {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
formatter := output.NewPaginatedFormatter(stdout, format)
|
||||
var emitErr error
|
||||
for _, page := range pages {
|
||||
scanResult := output.ScanForSafety("lark-cli fixture +emit", page, stderr)
|
||||
if scanResult.Blocked {
|
||||
emitErr = scanResult.BlockErr
|
||||
break
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(stderr, scanResult.Alert)
|
||||
}
|
||||
formatter.FormatPage(page)
|
||||
}
|
||||
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
|
||||
}
|
||||
|
||||
func runEmitterStreamPages(pages []interface{}, format string) emitterCapture {
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: stderr,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
Identity: "bot",
|
||||
})
|
||||
var emitErr error
|
||||
for _, page := range pages {
|
||||
if emitErr = emitter.StreamPage(page, output.StreamOptions{Format: format}); emitErr != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: emitErr}
|
||||
}
|
||||
|
||||
func TestEmitterCapturesNoticeAndColorDependencies(t *testing.T) {
|
||||
previousNotice := output.PendingNotice
|
||||
output.PendingNotice = func() map[string]interface{} {
|
||||
return map[string]interface{}{"source": "global"}
|
||||
}
|
||||
t.Cleanup(func() { output.PendingNotice = previousNotice })
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
stderr := &bytes.Buffer{}
|
||||
colorSeen := false
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: stdout,
|
||||
ErrOut: stderr,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
Identity: "bot",
|
||||
ColorEnabled: true,
|
||||
NoticeProvider: func() map[string]interface{} {
|
||||
return map[string]interface{}{"source": "captured"}
|
||||
},
|
||||
})
|
||||
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "json"}); err != nil {
|
||||
t.Fatalf("Emitter.Success() error = %v", err)
|
||||
}
|
||||
if strings.Contains(stdout.String(), "global") || !strings.Contains(stdout.String(), "captured") {
|
||||
t.Fatalf("notice source was not captured by Emitter:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
if err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{Format: "pretty",
|
||||
Pretty: func(w io.Writer, colorEnabled bool) error {
|
||||
colorSeen = colorEnabled
|
||||
_, err := fmt.Fprintln(w, "pretty")
|
||||
return err
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("Emitter.Success(pretty) error = %v", err)
|
||||
}
|
||||
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 {
|
||||
err error
|
||||
}
|
||||
|
||||
func (w failingEmitterWriter) Write([]byte) (int, error) { return 0, w.err }
|
||||
|
||||
func TestEmitterPropagatesOutputError(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONTENT_SAFETY_MODE", "off")
|
||||
sentinel := errors.New("write failed")
|
||||
emitter := output.NewEmitter(output.EmitterConfig{
|
||||
Out: failingEmitterWriter{err: sentinel},
|
||||
ErrOut: io.Discard,
|
||||
CommandPath: "lark-cli fixture +emit",
|
||||
})
|
||||
err := emitter.Success(map[string]interface{}{"id": "1"}, output.EmitOptions{
|
||||
Raw: true, Format: "json",
|
||||
JQ: ".data",
|
||||
})
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("Emitter.Success() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("Emitter.Success() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEmitterBytes(t *testing.T, legacy, current emitterCapture) {
|
||||
t.Helper()
|
||||
if legacy.stdout != current.stdout {
|
||||
t.Fatalf("stdout byte mismatch\nlegacy (%d bytes):\n%q\nEmitter (%d bytes):\n%q",
|
||||
len(legacy.stdout), legacy.stdout, len(current.stdout), current.stdout)
|
||||
}
|
||||
if legacy.stderr != current.stderr {
|
||||
t.Fatalf("stderr byte mismatch\nlegacy (%d bytes):\n%q\nEmitter (%d bytes):\n%q",
|
||||
len(legacy.stderr), legacy.stderr, len(current.stderr), current.stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEmitterGolden(t *testing.T, want emitterCaptureGolden, current emitterCapture) {
|
||||
t.Helper()
|
||||
if want.Stdout != current.stdout {
|
||||
t.Fatalf("stdout byte mismatch\ngolden (%d bytes):\n%q\ncurrent (%d bytes):\n%q",
|
||||
len(want.Stdout), want.Stdout, len(current.stdout), current.stdout)
|
||||
}
|
||||
if want.Stderr != current.stderr {
|
||||
t.Fatalf("stderr byte mismatch\ngolden (%d bytes):\n%q\ncurrent (%d bytes):\n%q",
|
||||
len(want.Stderr), want.Stderr, len(current.stderr), current.stderr)
|
||||
}
|
||||
got := captureEmitterGolden(t, current)
|
||||
if (want.Error == nil) != (got.Error == nil) {
|
||||
t.Fatalf("error presence mismatch: golden=%#v current=%#v", want.Error, got.Error)
|
||||
}
|
||||
if want.Error == nil {
|
||||
return
|
||||
}
|
||||
if want.Error.GoType != got.Error.GoType || want.Error.Message != got.Error.Message || want.Error.ExitCode != got.Error.ExitCode {
|
||||
t.Fatalf("error mismatch:\ngolden: %#v\ncurrent: %#v", want.Error, got.Error)
|
||||
}
|
||||
var wantJSON interface{}
|
||||
if err := json.Unmarshal(want.Error.JSON, &wantJSON); err != nil {
|
||||
t.Fatalf("decode golden error JSON: %v", err)
|
||||
}
|
||||
var gotJSON interface{}
|
||||
if err := json.Unmarshal(got.Error.JSON, &gotJSON); err != nil {
|
||||
t.Fatalf("decode current error JSON: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(wantJSON, gotJSON) {
|
||||
t.Fatalf("error JSON mismatch:\ngolden: %s\ncurrent: %s", want.Error.JSON, got.Error.JSON)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEquivalentError(t *testing.T, legacy, current error) {
|
||||
t.Helper()
|
||||
if (legacy == nil) != (current == nil) {
|
||||
t.Fatalf("error presence mismatch: legacy=%v Emitter=%v", legacy, current)
|
||||
}
|
||||
if legacy == nil {
|
||||
return
|
||||
}
|
||||
legacyProblem, legacyOK := errs.ProblemOf(legacy)
|
||||
currentProblem, currentOK := errs.ProblemOf(current)
|
||||
if legacyOK != currentOK {
|
||||
t.Fatalf("typed error mismatch: legacy=%T Emitter=%T", legacy, current)
|
||||
}
|
||||
if legacyOK && !reflect.DeepEqual(legacyProblem, currentProblem) {
|
||||
t.Fatalf("problem mismatch:\nlegacy: %#v\nEmitter: %#v", legacyProblem, currentProblem)
|
||||
}
|
||||
}
|
||||
@@ -34,27 +34,17 @@ func SuccessEnvelopeData(result interface{}) interface{} {
|
||||
// JSON output carries content-safety alerts inside the envelope. When jq is
|
||||
// applied, the alert may be filtered away, so warn mode also writes stderr.
|
||||
func WriteSuccessEnvelope(data interface{}, opts SuccessEnvelopeOptions) error {
|
||||
scanResult := ScanForSafety(opts.CommandPath, data, opts.ErrOut)
|
||||
if scanResult.Blocked {
|
||||
return scanResult.BlockErr
|
||||
}
|
||||
|
||||
env := Envelope{
|
||||
OK: true,
|
||||
Identity: opts.Identity,
|
||||
DryRun: opts.DryRun,
|
||||
Data: data,
|
||||
Notice: GetNotice(),
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
env.ContentSafetyAlert = scanResult.Alert
|
||||
}
|
||||
if opts.JqExpr != "" {
|
||||
if scanResult.Alert != nil && opts.ErrOut != nil {
|
||||
WriteAlertWarning(opts.ErrOut, scanResult.Alert)
|
||||
}
|
||||
return JqFilter(opts.Out, env, opts.JqExpr)
|
||||
}
|
||||
PrintJson(opts.Out, env)
|
||||
return nil
|
||||
return NewEmitter(EmitterConfig{
|
||||
Out: opts.Out,
|
||||
ErrOut: opts.ErrOut,
|
||||
CommandPath: opts.CommandPath,
|
||||
Identity: opts.Identity,
|
||||
NoticeProvider: GetNotice,
|
||||
}).Success(data, EmitOptions{
|
||||
Format: "",
|
||||
Raw: false,
|
||||
JQ: opts.JqExpr,
|
||||
DryRun: opts.DryRun,
|
||||
JQSafetyWarning: true,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -101,34 +101,44 @@ func ExtractItems(data interface{}) []interface{} {
|
||||
|
||||
// FormatValue formats a single response and writes it to w.
|
||||
func FormatValue(w io.Writer, data interface{}, format Format) {
|
||||
err := WriteFormatted(w, data, format)
|
||||
switch {
|
||||
case err == nil:
|
||||
return
|
||||
case isOutputMarshalError(err) && format == FormatNDJSON:
|
||||
legacyStderrf("ndjson marshal error: %v\n", err)
|
||||
case isOutputMarshalError(err):
|
||||
legacyStderrf("json marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteFormatted formats a single response and returns marshal or write errors.
|
||||
func WriteFormatted(w io.Writer, data interface{}, format Format) error {
|
||||
data = toGeneric(data)
|
||||
switch format {
|
||||
case FormatNDJSON:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
PrintNdjson(w, items)
|
||||
} else {
|
||||
PrintNdjson(w, data)
|
||||
return WriteNDJSON(w, items)
|
||||
}
|
||||
return WriteNDJSON(w, data)
|
||||
|
||||
case FormatTable:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
FormatAsTable(w, items)
|
||||
} else {
|
||||
FormatAsTable(w, data)
|
||||
return WriteTable(w, items)
|
||||
}
|
||||
return WriteTable(w, data)
|
||||
|
||||
case FormatCSV:
|
||||
items := ExtractItems(data)
|
||||
if items != nil {
|
||||
FormatAsCSV(w, items)
|
||||
} else {
|
||||
FormatAsCSV(w, data)
|
||||
return WriteCSV(w, items)
|
||||
}
|
||||
return WriteCSV(w, data)
|
||||
|
||||
default: // FormatJSON
|
||||
PrintJson(w, data)
|
||||
return WriteJSON(w, data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,49 +158,63 @@ func NewPaginatedFormatter(w io.Writer, format Format) *PaginatedFormatter {
|
||||
|
||||
// FormatPage formats one page of items.
|
||||
func (pf *PaginatedFormatter) FormatPage(data interface{}) {
|
||||
switch pf.Format {
|
||||
case FormatJSON, FormatNDJSON:
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
PrintNdjson(pf.W, arr)
|
||||
} else {
|
||||
PrintNdjson(pf.W, data)
|
||||
}
|
||||
|
||||
case FormatTable:
|
||||
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
|
||||
widths := computeColumnWidths(rows, cols)
|
||||
if isFirst {
|
||||
writeHeader(w, cols, widths)
|
||||
}
|
||||
for _, row := range rows {
|
||||
writeRow(w, row, cols, widths)
|
||||
}
|
||||
})
|
||||
|
||||
case FormatCSV:
|
||||
pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) {
|
||||
writeCSVRows(w, rows, cols, isFirst)
|
||||
})
|
||||
err := pf.WritePage(data)
|
||||
if isOutputMarshalError(err) && (pf.Format == FormatJSON || pf.Format == FormatNDJSON) {
|
||||
legacyStderrf("ndjson marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WritePage formats one page of items and returns marshal or write errors.
|
||||
func (pf *PaginatedFormatter) WritePage(data interface{}) error {
|
||||
switch pf.Format {
|
||||
case FormatJSON, FormatNDJSON:
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
return WriteNDJSON(pf.W, arr)
|
||||
}
|
||||
return WriteNDJSON(pf.W, data)
|
||||
|
||||
case FormatTable:
|
||||
return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
|
||||
widths := computeColumnWidths(rows, cols)
|
||||
if isFirst {
|
||||
if err := writeHeader(w, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
if err := writeRow(w, row, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
case FormatCSV:
|
||||
return pf.formatStructuredPage(data, func(w io.Writer, rows []map[string]string, cols []string, isFirst bool) error {
|
||||
return writeCSVRows(w, rows, cols, isFirst)
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatStructuredPage handles column-locking logic shared by table and csv.
|
||||
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool)) {
|
||||
func (pf *PaginatedFormatter) formatStructuredPage(data interface{}, emit func(io.Writer, []map[string]string, []string, bool) error) error {
|
||||
rows, pageCols, isList := prepareRows(data)
|
||||
if len(rows) == 0 {
|
||||
if pf.isFirstPage && isList {
|
||||
fmt.Fprintln(pf.W, "(empty)")
|
||||
_, err := fmt.Fprintln(pf.W, "(empty)")
|
||||
return err
|
||||
}
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
if pf.isFirstPage {
|
||||
// Lock columns from first page
|
||||
pf.cols = pageCols
|
||||
pf.isFirstPage = false
|
||||
emit(pf.W, rows, pf.cols, true)
|
||||
return emit(pf.W, rows, pf.cols, true)
|
||||
} else {
|
||||
// Reuse first page's columns — missing keys become empty, extra keys ignored
|
||||
emit(pf.W, rows, pf.cols, false)
|
||||
return emit(pf.W, rows, pf.cols, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ package output
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -15,12 +16,44 @@ import (
|
||||
// PrintJson prints data as formatted JSON to w.
|
||||
func PrintJson(w io.Writer, data interface{}) {
|
||||
injectNotice(data)
|
||||
if err := WriteJSON(w, data); isOutputMarshalError(err) {
|
||||
legacyStderrf("json marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
type outputMarshalError struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (e *outputMarshalError) Error() string {
|
||||
return e.err.Error()
|
||||
}
|
||||
|
||||
func (e *outputMarshalError) Unwrap() error {
|
||||
return e.err
|
||||
}
|
||||
|
||||
func isOutputMarshalError(err error) bool {
|
||||
var marshalErr *outputMarshalError
|
||||
return errors.As(err, &marshalErr)
|
||||
}
|
||||
|
||||
// legacyStderrf reports a leaf-formatter marshal/format failure on os.Stderr,
|
||||
// preserving the pre-Emitter behavior for direct (unmigrated) callers of the
|
||||
// Print*/FormatAs* wrappers. The Emitter never uses this — it returns typed
|
||||
// errors instead. Removed once the remaining direct callers migrate.
|
||||
func legacyStderrf(format string, args ...interface{}) {
|
||||
fmt.Fprintf(os.Stderr, format, args...) //nolint:forbidigo // legacy leaf-formatter stderr; removed in the output-ownership follow-up
|
||||
}
|
||||
|
||||
// WriteJSON writes data as formatted JSON to w and returns marshal or write errors.
|
||||
func WriteJSON(w io.Writer, data interface{}) error {
|
||||
b, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "json marshal error: %v\n", err)
|
||||
return
|
||||
return &outputMarshalError{err: err}
|
||||
}
|
||||
fmt.Fprintln(w, string(b))
|
||||
_, err = fmt.Fprintln(w, string(b))
|
||||
return err
|
||||
}
|
||||
|
||||
// injectNotice adds a "_notice" field into CLI envelope maps.
|
||||
@@ -50,21 +83,38 @@ func injectNotice(data interface{}) {
|
||||
|
||||
// PrintNdjson prints data as NDJSON (Newline Delimited JSON) to w.
|
||||
func PrintNdjson(w io.Writer, data interface{}) {
|
||||
emit := func(item interface{}) {
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
if err := WriteNDJSON(w, item); isOutputMarshalError(err) {
|
||||
legacyStderrf("ndjson marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := WriteNDJSON(w, data); isOutputMarshalError(err) {
|
||||
legacyStderrf("ndjson marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteNDJSON writes data as NDJSON and returns marshal or write errors.
|
||||
func WriteNDJSON(w io.Writer, data interface{}) error {
|
||||
emit := func(item interface{}) error {
|
||||
b, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "ndjson marshal error: %v\n", err)
|
||||
return
|
||||
return &outputMarshalError{err: err}
|
||||
}
|
||||
fmt.Fprintln(w, string(b))
|
||||
_, err = fmt.Fprintln(w, string(b))
|
||||
return err
|
||||
}
|
||||
if arr, ok := data.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
emit(item)
|
||||
if err := emit(item); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
emit(data)
|
||||
return nil
|
||||
}
|
||||
return emit(data)
|
||||
}
|
||||
|
||||
func cellStr(val interface{}) string {
|
||||
|
||||
@@ -16,50 +16,69 @@ const maxColWidth = 100
|
||||
// - map[string]interface{} (single object) → key-value two-column table
|
||||
// - empty array → "(empty)"
|
||||
func FormatAsTable(w io.Writer, data interface{}) {
|
||||
FormatAsTablePaginated(w, data, true)
|
||||
if err := WriteTable(w, data); isOutputMarshalError(err) {
|
||||
legacyStderrf("json marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteTable formats data as a table and returns marshal or write errors.
|
||||
func WriteTable(w io.Writer, data interface{}) error {
|
||||
return WriteTablePaginated(w, data, true)
|
||||
}
|
||||
|
||||
// FormatAsTablePaginated formats data as a table with pagination awareness.
|
||||
// When isFirstPage is true, outputs the header; otherwise only data rows.
|
||||
func FormatAsTablePaginated(w io.Writer, data interface{}, isFirstPage bool) {
|
||||
if err := WriteTablePaginated(w, data, isFirstPage); isOutputMarshalError(err) {
|
||||
legacyStderrf("json marshal error: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteTablePaginated formats data as a table and returns marshal or write errors.
|
||||
func WriteTablePaginated(w io.Writer, data interface{}, isFirstPage bool) error {
|
||||
rows, cols, isList := prepareRows(data)
|
||||
if cols == nil {
|
||||
if isList {
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
} else {
|
||||
// Not a list and not an object — print as JSON fallback
|
||||
PrintJson(w, data)
|
||||
return WriteJSON(w, data)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if len(rows) == 0 {
|
||||
if isFirstPage {
|
||||
fmt.Fprintln(w, "(empty)")
|
||||
_, err := fmt.Fprintln(w, "(empty)")
|
||||
return err
|
||||
}
|
||||
return
|
||||
return nil
|
||||
}
|
||||
|
||||
if !isList {
|
||||
// Single object: key-value two-column format
|
||||
formatKeyValueTable(w, rows[0], cols)
|
||||
return
|
||||
return formatKeyValueTable(w, rows[0], cols)
|
||||
}
|
||||
|
||||
// Calculate column widths (clamped to maxColWidth)
|
||||
widths := computeColumnWidths(rows, cols)
|
||||
|
||||
if isFirstPage {
|
||||
writeHeader(w, cols, widths)
|
||||
if err := writeHeader(w, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
writeRow(w, row, cols, widths)
|
||||
if err := writeRow(w, row, cols, widths); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// formatKeyValueTable renders a single object as a two-column key-value table.
|
||||
func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) {
|
||||
func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) error {
|
||||
maxKeyWidth := 0
|
||||
for _, col := range cols {
|
||||
kw := stringWidth(col)
|
||||
@@ -71,8 +90,11 @@ func formatKeyValueTable(w io.Writer, row map[string]string, cols []string) {
|
||||
for _, col := range cols {
|
||||
val := row[col]
|
||||
val = truncateToWidth(val, maxColWidth)
|
||||
fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val)
|
||||
if _, err := fmt.Fprintf(w, "%s %s\n", padToWidth(col, maxKeyWidth), val); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// computeColumnWidths returns display widths for each column, clamped to maxColWidth.
|
||||
@@ -99,25 +121,29 @@ func computeColumnWidths(rows []map[string]string, cols []string) []int {
|
||||
}
|
||||
|
||||
// writeHeader writes the header row and separator line.
|
||||
func writeHeader(w io.Writer, cols []string, widths []int) {
|
||||
func writeHeader(w io.Writer, cols []string, widths []int) error {
|
||||
var header []string
|
||||
var sep []string
|
||||
for i, col := range cols {
|
||||
header = append(header, padToWidth(col, widths[i]))
|
||||
sep = append(sep, strings.Repeat("─", widths[i]))
|
||||
}
|
||||
fmt.Fprintln(w, strings.Join(header, " "))
|
||||
fmt.Fprintln(w, strings.Join(sep, " "))
|
||||
if _, err := fmt.Fprintln(w, strings.Join(header, " ")); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := fmt.Fprintln(w, strings.Join(sep, " "))
|
||||
return err
|
||||
}
|
||||
|
||||
// writeRow writes a single data row.
|
||||
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) {
|
||||
func writeRow(w io.Writer, row map[string]string, cols []string, widths []int) error {
|
||||
var cells []string
|
||||
for i, col := range cols {
|
||||
val := truncateToWidth(row[col], widths[i])
|
||||
cells = append(cells, padToWidth(val, widths[i]))
|
||||
}
|
||||
fmt.Fprintln(w, strings.Join(cells, " "))
|
||||
_, err := fmt.Fprintln(w, strings.Join(cells, " "))
|
||||
return err
|
||||
}
|
||||
|
||||
// padToWidth pads a string with spaces to reach the target display width.
|
||||
|
||||
107
internal/output/testdata/runtime_context_legacy.golden.json
vendored
Normal file
107
internal/output/testdata/runtime_context_legacy.golden.json
vendored
Normal file
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"cases": {
|
||||
"csv": {
|
||||
"stdout": "id,name\n1,Alice\n2,Bob\n",
|
||||
"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",
|
||||
"stderr": ""
|
||||
},
|
||||
"jq_invalid_expression": {
|
||||
"stdout": "",
|
||||
"stderr": "error: invalid jq expression: unexpected EOF\n",
|
||||
"error": {
|
||||
"go_type": "*errs.ValidationError",
|
||||
"json": {
|
||||
"type": "validation",
|
||||
"subtype": "invalid_argument",
|
||||
"message": "invalid jq expression: unexpected EOF"
|
||||
},
|
||||
"message": "invalid jq expression: unexpected EOF",
|
||||
"exit_code": 2
|
||||
}
|
||||
},
|
||||
"jq_safety_alert_without_stderr_warning": {
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"jq_scalar": {
|
||||
"stdout": "Alice\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"json_object": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"enabled\": true,\n \"id\": \"1\"\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"metadata": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": [\n {\n \"id\": \"1\"\n }\n ],\n \"meta\": {\n \"count\": 1,\n \"rollback\": \"lark-cli fixture rollback\"\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"ndjson": {
|
||||
"stdout": "{\"id\":\"1\"}\n{\"id\":\"2\"}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"notice": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n },\n \"_notice\": {\n \"update\": {\n \"latest\": \"9.9.9\"\n }\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"partial_failure_ok_false": {
|
||||
"stdout": "{\n \"ok\": false,\n \"identity\": \"bot\",\n \"data\": {\n \"failed\": 1,\n \"succeeded\": 1\n }\n}\n",
|
||||
"stderr": "",
|
||||
"error": {
|
||||
"go_type": "*output.PartialFailureError",
|
||||
"json": {
|
||||
"Code": 1
|
||||
},
|
||||
"message": "partial failure (exit 1)",
|
||||
"exit_code": 1
|
||||
}
|
||||
},
|
||||
"pretty": {
|
||||
"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",
|
||||
"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",
|
||||
"stderr": ""
|
||||
},
|
||||
"scanner_block": {
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"error": {
|
||||
"go_type": "*errs.ContentSafetyError",
|
||||
"json": {
|
||||
"type": "policy",
|
||||
"subtype": "content_safety",
|
||||
"message": "content safety violation detected (rules: fixture-rule)",
|
||||
"rules": [
|
||||
"fixture-rule"
|
||||
]
|
||||
},
|
||||
"message": "content safety violation detected (rules: fixture-rule)",
|
||||
"exit_code": 6
|
||||
}
|
||||
},
|
||||
"scanner_error_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"
|
||||
}
|
||||
}
|
||||
}
|
||||
41
internal/output/testdata/write_success_envelope_legacy.golden.json
vendored
Normal file
41
internal/output/testdata/write_success_envelope_legacy.golden.json
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"cases": {
|
||||
"dry_run": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"dry_run\": true,\n \"data\": {\n \"api\": []\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"jq": {
|
||||
"stdout": "1\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"jq_safety_warning": {
|
||||
"stdout": "1\n",
|
||||
"stderr": "warning: content safety alert from emitter-oracle (rules: fixture-rule)\n"
|
||||
},
|
||||
"json": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"notice": {
|
||||
"stdout": "{\n \"ok\": true,\n \"identity\": \"bot\",\n \"data\": {\n \"id\": \"1\"\n },\n \"_notice\": {\n \"update\": {\n \"latest\": \"9.9.9\"\n }\n }\n}\n",
|
||||
"stderr": ""
|
||||
},
|
||||
"scanner_block": {
|
||||
"stdout": "",
|
||||
"stderr": "",
|
||||
"error": {
|
||||
"go_type": "*errs.ContentSafetyError",
|
||||
"json": {
|
||||
"type": "policy",
|
||||
"subtype": "content_safety",
|
||||
"message": "content safety violation detected (rules: fixture-rule)",
|
||||
"rules": [
|
||||
"fixture-rule"
|
||||
]
|
||||
},
|
||||
"message": "content safety violation detected (rules: fixture-rule)",
|
||||
"exit_code": 6
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/internal/qualitygate/facts"
|
||||
"github.com/larksuite/cli/internal/qualitygate/manifest"
|
||||
"github.com/larksuite/cli/internal/qualitygate/report"
|
||||
@@ -726,7 +727,11 @@ func appendDryRunArg(raw string) ([]string, error) {
|
||||
return nil, fmt.Errorf("not a lark-cli command")
|
||||
}
|
||||
argv = truncateShellTail(argv)
|
||||
argv = forceDryRunJSONFormat(argv)
|
||||
var jqValid bool
|
||||
argv, jqValid = stripDryRunJQFilter(argv)
|
||||
if jqValid {
|
||||
argv = forceDryRunJSONFormat(argv)
|
||||
}
|
||||
hasDryRunArg := false
|
||||
dryRunEnabled := false
|
||||
for _, arg := range argv[1:] {
|
||||
@@ -775,6 +780,73 @@ func truncateShellTail(argv []string) []string {
|
||||
return argv
|
||||
}
|
||||
|
||||
// stripDryRunJQFilter removes valid output-only jq filters from the synthetic
|
||||
// dry-run invocation. Invalid jq syntax and incompatible output flags are left
|
||||
// untouched so the real CLI execution still rejects the documented command.
|
||||
// The bool reports whether other output normalization remains safe.
|
||||
func stripDryRunJQFilter(argv []string) ([]string, bool) {
|
||||
jqExpr, outputPath, format, hasJQ, jqHasValue := dryRunOutputFlags(argv)
|
||||
if !hasJQ {
|
||||
return argv, true
|
||||
}
|
||||
if !jqHasValue || output.ValidateJqFlags(jqExpr, outputPath, format) != nil {
|
||||
return argv, false
|
||||
}
|
||||
|
||||
out := make([]string, 0, len(argv))
|
||||
for i := 0; i < len(argv); i++ {
|
||||
arg := argv[i]
|
||||
switch {
|
||||
case arg == "--":
|
||||
return append(out, argv[i:]...), true
|
||||
case arg == "--jq" || arg == "-q":
|
||||
i++
|
||||
case strings.HasPrefix(arg, "--jq=") || strings.HasPrefix(arg, "-q="):
|
||||
continue
|
||||
default:
|
||||
out = append(out, arg)
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func dryRunOutputFlags(argv []string) (jqExpr, outputPath, format string, hasJQ, jqHasValue bool) {
|
||||
for i := 1; i < len(argv); i++ {
|
||||
arg := argv[i]
|
||||
if arg == "--" {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case arg == "--jq" || arg == "-q":
|
||||
hasJQ = true
|
||||
jqHasValue = i+1 < len(argv)
|
||||
if jqHasValue {
|
||||
jqExpr = argv[i+1]
|
||||
i++
|
||||
}
|
||||
case strings.HasPrefix(arg, "--jq=") || strings.HasPrefix(arg, "-q="):
|
||||
hasJQ = true
|
||||
jqHasValue = true
|
||||
jqExpr = arg[strings.IndexByte(arg, '=')+1:]
|
||||
case arg == "--output":
|
||||
if i+1 < len(argv) {
|
||||
outputPath = argv[i+1]
|
||||
i++
|
||||
}
|
||||
case strings.HasPrefix(arg, "--output="):
|
||||
outputPath = strings.TrimPrefix(arg, "--output=")
|
||||
case arg == "--format":
|
||||
if i+1 < len(argv) {
|
||||
format = argv[i+1]
|
||||
i++
|
||||
}
|
||||
case strings.HasPrefix(arg, "--format="):
|
||||
format = strings.TrimPrefix(arg, "--format=")
|
||||
}
|
||||
}
|
||||
return jqExpr, outputPath, format, hasJQ, jqHasValue
|
||||
}
|
||||
|
||||
func dryRunFlagExplicitlyTrue(arg string) bool {
|
||||
value, ok := strings.CutPrefix(arg, "--dry-run=")
|
||||
if !ok {
|
||||
|
||||
@@ -194,6 +194,38 @@ func TestRunDryRunsIgnoresTrailingShellComment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsIgnoresJQFilterWhenValidatingRequestPreview(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/flags"}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
Path: "im +flag-list",
|
||||
Runnable: true,
|
||||
Identities: []string{"user"},
|
||||
Flags: []manifest.Flag{
|
||||
{Name: "as", TakesValue: true},
|
||||
{Name: "page-all"},
|
||||
{Name: "jq", Shorthand: "q", TakesValue: true},
|
||||
{Name: "dry-run"},
|
||||
},
|
||||
}}}
|
||||
ex := skillscan.Example{
|
||||
Raw: `lark-cli im +flag-list --as user --page-all -q '.data.flag_items[-1]'`,
|
||||
SourceFile: "skills/lark-im/references/lark-im-flag-list.md",
|
||||
Line: 26,
|
||||
}
|
||||
|
||||
diags, facts := RunDryRuns(context.Background(), cliBin, m, []skillscan.Example{ex})
|
||||
if len(diags) != 0 {
|
||||
t.Fatalf("RunDryRuns() diagnostics = %#v", diags)
|
||||
}
|
||||
if len(facts) != 1 || !facts[0].Executable || facts[0].SkipReason != "" {
|
||||
t.Fatalf("jq example should remain executable: %#v", facts)
|
||||
}
|
||||
wantArgs := []string{"im", "+flag-list", "--as", "user", "--page-all", "--dry-run"}
|
||||
if gotArgs := readArgs(t, argsPath); !reflect.DeepEqual(gotArgs, wantArgs) {
|
||||
t.Fatalf("fake CLI args = %#v, want %#v", gotArgs, wantArgs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunDryRunsMaterializesPlaceholdersInsideJSONFlags(t *testing.T) {
|
||||
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/im/v1/messages","params":{"chat_id":"oc_test123","page_token":"page_test123"}}]}`)
|
||||
m := manifest.Manifest{Commands: []manifest.Command{{
|
||||
@@ -795,6 +827,72 @@ func TestAppendDryRunArgForcesInlineJSONFormat(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgRemovesJQFilter(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "short split",
|
||||
raw: `lark-cli im +flag-list --page-all -q '.data.flag_items[-1]'`,
|
||||
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "long split",
|
||||
raw: `lark-cli im +flag-list --jq '.data.flag_items[].item_id' --page-all`,
|
||||
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "short inline",
|
||||
raw: `lark-cli im +flag-list -q='.data.flag_items[-1]' --page-all`,
|
||||
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "long inline",
|
||||
raw: `lark-cli im +flag-list --jq='.data.flag_items[-1]' --page-all`,
|
||||
want: []string{"im", "+flag-list", "--page-all", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "missing value remains invalid",
|
||||
raw: `lark-cli im +flag-list --page-all --jq`,
|
||||
want: []string{"im", "+flag-list", "--page-all", "--jq", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "next flag is not accepted as jq expression",
|
||||
raw: `lark-cli im +flag-list --jq --page-all`,
|
||||
want: []string{"im", "+flag-list", "--jq", "--page-all", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "invalid expression remains invalid",
|
||||
raw: `lark-cli im +flag-list --jq 'invalid[' --page-all`,
|
||||
want: []string{"im", "+flag-list", "--jq", "invalid[", "--page-all", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "incompatible pretty format remains invalid",
|
||||
raw: `lark-cli im +flag-list --jq '.data' --format pretty`,
|
||||
want: []string{"im", "+flag-list", "--jq", ".data", "--format", "pretty", "--dry-run"},
|
||||
},
|
||||
{
|
||||
name: "compatible json format preserves request preview",
|
||||
raw: `lark-cli im +flag-list --jq '.data' --format json`,
|
||||
want: []string{"im", "+flag-list", "--format", "json", "--dry-run"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := appendDryRunArg(tt.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("appendDryRunArg() error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Fatalf("appendDryRunArg() = %#v, want %#v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDryRunArgPreservesNonPrettyFormat(t *testing.T) {
|
||||
for _, raw := range []string{
|
||||
"lark-cli mail +watch --format data --dry-run",
|
||||
|
||||
@@ -101,6 +101,7 @@ func TestSelectRecommendedScope_Empty(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestComputeMinimumScopeSet(t *testing.T) {
|
||||
ensureFreshRegistry(t)
|
||||
minSet := ComputeMinimumScopeSet("user")
|
||||
if len(minSet) == 0 {
|
||||
if len(ListFromMetaProjects()) == 0 {
|
||||
|
||||
72
internal/registry/registrytest/fixture_meta.json
Normal file
72
internal/registry/registrytest/fixture_meta.json
Normal file
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"services": [
|
||||
{
|
||||
"name": "calendar",
|
||||
"version": "v4",
|
||||
"title": "Calendar API",
|
||||
"servicePath": "/open-apis/calendar/v4",
|
||||
"resources": {
|
||||
"events": {
|
||||
"methods": {
|
||||
"create": {
|
||||
"path": "calendars/{calendar_id}/events",
|
||||
"httpMethod": "POST",
|
||||
"risk": "write",
|
||||
"scopes": [
|
||||
"calendar:calendar.event:create"
|
||||
],
|
||||
"parameters": {
|
||||
"calendar_id": {
|
||||
"type": "string",
|
||||
"location": "path",
|
||||
"required": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "im",
|
||||
"version": "v1",
|
||||
"title": "IM API",
|
||||
"servicePath": "/open-apis/im/v1",
|
||||
"resources": {
|
||||
"chat.members": {
|
||||
"methods": {
|
||||
"create": {
|
||||
"path": "chats/{chat_id}/members",
|
||||
"httpMethod": "POST",
|
||||
"risk": "write",
|
||||
"scopes": [
|
||||
"im:chat",
|
||||
"im:chat.members:write_only"
|
||||
],
|
||||
"parameters": {
|
||||
"chat_id": {
|
||||
"type": "string",
|
||||
"location": "path",
|
||||
"required": true
|
||||
},
|
||||
"member_id_type": {
|
||||
"type": "string",
|
||||
"location": "query",
|
||||
"required": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "task",
|
||||
"version": "v2",
|
||||
"title": "Task API",
|
||||
"servicePath": "/open-apis/task/v2",
|
||||
"resources": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
146
internal/registry/registrytest/registrytest.go
Normal file
146
internal/registry/registrytest/registrytest.go
Normal file
@@ -0,0 +1,146 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
// Package registrytest seeds the registry with a tracked metadata fixture so
|
||||
// command-tree tests pass on a clean checkout — no `make fetch_meta`, no
|
||||
// network, no user cache. TestMain funcs of packages that build service
|
||||
// commands call Seed after redirecting LARKSUITE_CLI_CONFIG_DIR.
|
||||
package registrytest
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
"github.com/larksuite/cli/internal/vfs"
|
||||
)
|
||||
|
||||
// fixtureMetaJSON is a trimmed snapshot of the generated meta_data.json
|
||||
// holding only the calendar, im and task services that registry-backed tests
|
||||
// assert against. Its version is pinned to "0.0.1": newer than the empty
|
||||
// embedded stub ("0.0.0") so it wins on a clean checkout, older than any real
|
||||
// generated catalog ("1.0.0"+) so a `make fetch_meta` build keeps testing the
|
||||
// full embedded data.
|
||||
//
|
||||
//go:embed fixture_meta.json
|
||||
var fixtureMetaJSON []byte
|
||||
|
||||
// Seed writes fixtureMetaJSON into the registry remote-meta cache under
|
||||
// LARKSUITE_CLI_CONFIG_DIR and eagerly initializes the registry. testRoot must
|
||||
// be the temporary root created by the caller's TestMain; Seed rejects a config
|
||||
// directory outside it before performing any write. The cache
|
||||
// meta is stamped fresh so Init never sync-fetches or background-refreshes
|
||||
// over the network. Eager Init pins the catalog for the whole test process before
|
||||
// any individual test can re-point LARKSUITE_CLI_CONFIG_DIR elsewhere.
|
||||
//
|
||||
// The caller's TestMain must set LARKSUITE_CLI_CONFIG_DIR beneath testRoot
|
||||
// first; Seed refuses unset, mismatched, or escaping paths so it can never
|
||||
// write into a developer's real ~/.lark-cli.
|
||||
func Seed(testRoot string) error {
|
||||
configDir := os.Getenv("LARKSUITE_CLI_CONFIG_DIR")
|
||||
if err := validateConfigDir(testRoot, configDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var fixture struct {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
if err := json.Unmarshal(fixtureMetaJSON, &fixture); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cacheDir := filepath.Join(configDir, "cache")
|
||||
if err := vfs.MkdirAll(cacheDir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.WriteFile(filepath.Join(cacheDir, "remote_meta.json"), fixtureMetaJSON, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
cacheMeta, err := json.Marshal(registry.CacheMeta{
|
||||
LastCheckAt: time.Now().Unix(),
|
||||
Version: fixture.Version,
|
||||
Brand: string(core.BrandFeishu),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := vfs.WriteFile(filepath.Join(cacheDir, "remote_meta.meta.json"), cacheMeta, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Neutralize ambient knobs that would defeat the seeding: an inherited
|
||||
// LARKSUITE_CLI_REMOTE_META=off would stop Init from reading the seeded
|
||||
// cache at all, and LARKSUITE_CLI_META_TTL=0 would expire the freshness
|
||||
// stamp and start a background network refresh from inside unit tests.
|
||||
if err := os.Unsetenv("LARKSUITE_CLI_REMOTE_META"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Unsetenv("LARKSUITE_CLI_META_TTL"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
registry.Init()
|
||||
|
||||
// Init is a sync.Once, so the seed is pinned for the whole test process.
|
||||
// Turning remote metadata off afterwards cannot un-seed anything; it is a
|
||||
// guard for any future post-Init code path that might consult the remote
|
||||
// cache again after a test re-points LARKSUITE_CLI_CONFIG_DIR elsewhere.
|
||||
if err := os.Setenv("LARKSUITE_CLI_REMOTE_META", "off"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Self-check: both the fixture and any real generated catalog contain the
|
||||
// im service. If it is missing, the cache seeding silently stopped working
|
||||
// (e.g. the registry cache file names or freshness semantics changed) and
|
||||
// every registry-backed test would fail confusingly — fail loudly here
|
||||
// instead, pointing at this package.
|
||||
merged, ok := registry.ServiceTyped("im")
|
||||
if !ok {
|
||||
return errors.New("registrytest.Seed: registry has no im service after seeding — " +
|
||||
"the remote-cache format in internal/registry/remote.go may have changed; update registrytest to match")
|
||||
}
|
||||
|
||||
// Self-check: on a fetch_meta build the real embedded catalog must win over
|
||||
// the 0.0.1 fixture. If the merged im service diverges from the embedded
|
||||
// one, the version arbitration flipped (e.g. the generated catalog version
|
||||
// stopped parsing as semver) and unit tests would silently run against the
|
||||
// stale trimmed fixture instead of the fresh catalog.
|
||||
for _, service := range registry.EmbeddedServicesTyped() {
|
||||
if service.Name != "im" {
|
||||
continue
|
||||
}
|
||||
if service.Version != merged.Version {
|
||||
return errors.New("registrytest.Seed: the fixture shadowed the real embedded catalog — " +
|
||||
"check the meta_data.json version against the fixture's \"0.0.1\" arbitration in this package")
|
||||
}
|
||||
break
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateConfigDir guards the one real hazard: a TestMain wiring mistake
|
||||
// pointing LARKSUITE_CLI_CONFIG_DIR at a developer's real directory. Both
|
||||
// paths come from the caller's own MkdirTemp, so a plain containment check
|
||||
// is enough.
|
||||
func validateConfigDir(testRoot, configDir string) error {
|
||||
if testRoot == "" || configDir == "" {
|
||||
return errors.New("registrytest.Seed: test root and config dir must be set")
|
||||
}
|
||||
if !filepath.IsAbs(testRoot) || !filepath.IsAbs(configDir) {
|
||||
return errors.New("registrytest.Seed: test root and config dir must be absolute")
|
||||
}
|
||||
rel, err := filepath.Rel(filepath.Clean(testRoot), filepath.Clean(configDir))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return errors.New("registrytest.Seed: config dir must stay inside the test root")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
229
internal/registry/registrytest/registrytest_test.go
Normal file
229
internal/registry/registrytest/registrytest_test.go
Normal file
@@ -0,0 +1,229 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registrytest
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/internal/meta"
|
||||
"github.com/larksuite/cli/internal/registry"
|
||||
)
|
||||
|
||||
func TestValidateConfigDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
tests := []struct {
|
||||
name string
|
||||
testRoot string
|
||||
configDir string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "equal", testRoot: root, configDir: root},
|
||||
{name: "child", testRoot: root, configDir: filepath.Join(root, "config")},
|
||||
{
|
||||
name: "sibling",
|
||||
testRoot: root,
|
||||
configDir: filepath.Join(filepath.Dir(root), "outside"),
|
||||
wantErr: true,
|
||||
},
|
||||
{name: "empty root", configDir: root, wantErr: true},
|
||||
{name: "empty config", testRoot: root, wantErr: true},
|
||||
{name: "relative root", testRoot: "relative", configDir: root, wantErr: true},
|
||||
{name: "relative config", testRoot: root, configDir: "relative", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateConfigDir(tt.testRoot, tt.configDir)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateConfigDir() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixtureContract(t *testing.T) {
|
||||
if len(fixtureMetaJSON) > 20<<10 {
|
||||
t.Fatalf("fixture size = %d, want <= %d", len(fixtureMetaJSON), 20<<10)
|
||||
}
|
||||
reg, err := meta.Parse(fixtureMetaJSON)
|
||||
if err != nil {
|
||||
t.Fatalf("meta.Parse() error = %v", err)
|
||||
}
|
||||
if reg.Version != "0.0.1" {
|
||||
t.Fatalf("fixture version = %q, want 0.0.1", reg.Version)
|
||||
}
|
||||
|
||||
gotNames := make([]string, 0, len(reg.Services))
|
||||
for _, service := range reg.Services {
|
||||
gotNames = append(gotNames, service.Name)
|
||||
}
|
||||
sort.Strings(gotNames)
|
||||
if !slices.Equal(gotNames, []string{"calendar", "im", "task"}) {
|
||||
t.Fatalf("fixture services = %v, want [calendar im task]", gotNames)
|
||||
}
|
||||
|
||||
calendarCreate := fixtureMethod(t, reg, "calendar", "events", "create")
|
||||
assertMethodContract(t, calendarCreate, "calendars/{calendar_id}/events", http.MethodPost)
|
||||
calendarID, ok := calendarCreate.Parameters["calendar_id"]
|
||||
if !ok || calendarID.Location != "path" || !calendarID.Required {
|
||||
t.Fatalf("calendar_id = %+v, want required path parameter", calendarID)
|
||||
}
|
||||
if !slices.Contains(calendarCreate.Scopes, "calendar:calendar.event:create") {
|
||||
t.Fatalf("calendar create scopes = %v, want calendar:calendar.event:create", calendarCreate.Scopes)
|
||||
}
|
||||
|
||||
imCreate := fixtureMethod(t, reg, "im", "chat.members", "create")
|
||||
assertMethodContract(t, imCreate, "chats/{chat_id}/members", http.MethodPost)
|
||||
chatID, ok := imCreate.Parameters["chat_id"]
|
||||
if !ok || chatID.Location != "path" || !chatID.Required {
|
||||
t.Fatalf("chat_id = %+v, want required path parameter", chatID)
|
||||
}
|
||||
memberIDType, ok := imCreate.Parameters["member_id_type"]
|
||||
if !ok || memberIDType.Location != "query" || memberIDType.Required {
|
||||
t.Fatalf("member_id_type = %+v, want optional query parameter", memberIDType)
|
||||
}
|
||||
if imCreate.Risk != "write" {
|
||||
t.Fatalf("im create risk = %q, want write", imCreate.Risk)
|
||||
}
|
||||
for _, scope := range []string{"im:chat", "im:chat.members:write_only"} {
|
||||
if !slices.Contains(imCreate.Scopes, scope) {
|
||||
t.Fatalf("im create scopes = %v, want %s", imCreate.Scopes, scope)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fixtureMethod(t *testing.T, reg meta.Registry, serviceName, resourceName, methodName string) meta.Method {
|
||||
t.Helper()
|
||||
for _, service := range reg.Services {
|
||||
if service.Name != serviceName {
|
||||
continue
|
||||
}
|
||||
resource, ok := service.Resource(resourceName)
|
||||
if !ok {
|
||||
t.Fatalf("fixture service %s has no resource %s", serviceName, resourceName)
|
||||
}
|
||||
method, ok := resource.Method(methodName)
|
||||
if !ok {
|
||||
t.Fatalf("fixture resource %s.%s has no method %s", serviceName, resourceName, methodName)
|
||||
}
|
||||
return method
|
||||
}
|
||||
t.Fatalf("fixture has no service %s", serviceName)
|
||||
return meta.Method{}
|
||||
}
|
||||
|
||||
func assertMethodContract(t *testing.T, method meta.Method, path, httpMethod string) {
|
||||
t.Helper()
|
||||
if method.Path != path || method.HTTPMethod != httpMethod {
|
||||
t.Fatalf("method = %s %s, want %s %s", method.HTTPMethod, method.Path, httpMethod, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedRejectsUnsafeConfigDir pins Seed's guard: it must return before
|
||||
// writing anything when LARKSUITE_CLI_CONFIG_DIR is unset or escapes the
|
||||
// caller's test root, so a TestMain wiring mistake can never touch a
|
||||
// developer's real ~/.lark-cli.
|
||||
func TestSeedRejectsUnsafeConfigDir(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
t.Run("unset config dir", func(t *testing.T) {
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", "")
|
||||
if err := Seed(root); err == nil {
|
||||
t.Fatal("Seed() error = nil, want unset config dir rejection")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("config dir outside test root", func(t *testing.T) {
|
||||
outside := t.TempDir()
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", outside)
|
||||
if err := Seed(root); err == nil {
|
||||
t.Fatal("Seed() error = nil, want containment rejection")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(outside, "cache")); err == nil {
|
||||
t.Fatal("Seed wrote into the rejected config dir")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSeedWritesFixtureAndInitializesRegistry covers the seeding happy path:
|
||||
// cache files land under the config dir, the registry initializes from them,
|
||||
// and both self-checks pass.
|
||||
func TestSeedWritesFixtureAndInitializesRegistry(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, "config")
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
|
||||
if err := Seed(root); err != nil {
|
||||
t.Fatalf("Seed() error = %v, want nil", err)
|
||||
}
|
||||
for _, name := range []string{"remote_meta.json", "remote_meta.meta.json"} {
|
||||
if _, err := os.Stat(filepath.Join(configDir, "cache", name)); err != nil {
|
||||
t.Errorf("cache file %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
if got := os.Getenv("LARKSUITE_CLI_REMOTE_META"); got != "off" {
|
||||
t.Errorf("LARKSUITE_CLI_REMOTE_META = %q, want off after seeding", got)
|
||||
}
|
||||
for _, service := range []string{"calendar", "im", "task"} {
|
||||
if _, ok := registry.ServiceTyped(service); !ok {
|
||||
t.Errorf("registry missing service %s after seeding", service)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedPropagatesCacheSetupFailures pins that filesystem failures while
|
||||
// materializing the cache surface as errors instead of leaving the registry
|
||||
// silently unseeded. Each obstacle is a same-named file/directory in the
|
||||
// way, which fails on every platform without permission tricks.
|
||||
func TestSeedPropagatesCacheSetupFailures(t *testing.T) {
|
||||
seedWith := func(t *testing.T, prepare func(root, configDir string)) error {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, "config")
|
||||
prepare(root, configDir)
|
||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", configDir)
|
||||
return Seed(root)
|
||||
}
|
||||
|
||||
t.Run("cache dir creation fails", func(t *testing.T) {
|
||||
err := seedWith(t, func(root, configDir string) {
|
||||
// config is a regular file, so MkdirAll(config/cache) fails.
|
||||
if err := os.WriteFile(configDir, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Seed() error = nil, want cache dir creation failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("fixture write fails", func(t *testing.T) {
|
||||
err := seedWith(t, func(root, configDir string) {
|
||||
// remote_meta.json is a directory, so WriteFile fails.
|
||||
if err := os.MkdirAll(filepath.Join(configDir, "cache", "remote_meta.json"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Seed() error = nil, want fixture write failure")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("cache meta write fails", func(t *testing.T) {
|
||||
err := seedWith(t, func(root, configDir string) {
|
||||
// remote_meta.meta.json is a directory, so WriteFile fails.
|
||||
if err := os.MkdirAll(filepath.Join(configDir, "cache", "remote_meta.meta.json"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("Seed() error = nil, want cache meta write failure")
|
||||
}
|
||||
})
|
||||
}
|
||||
27
internal/registry/testmain_test.go
Normal file
27
internal/registry/testmain_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package registry
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
root, err := os.MkdirTemp("", "lark-cli-registry-test-*")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if err := os.Setenv("LARKSUITE_CLI_CONFIG_DIR", filepath.Join(root, "config")); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
code := m.Run()
|
||||
// A test that ran Init without a trailing resetInit can leave a background
|
||||
// refresh goroutine alive; removing the temp root while it writes would
|
||||
// let it recreate the directory after cleanup. Wait it out first.
|
||||
waitBackgroundRefresh()
|
||||
_ = os.RemoveAll(root)
|
||||
os.Exit(code)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.73",
|
||||
"version": "1.0.74",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -104,6 +104,22 @@ func TestDryRunFieldOps(t *testing.T) {
|
||||
assertDryRunContains(t, dryRunFieldUpdate(ctx, rt), "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
|
||||
assertDryRunContains(t, dryRunFieldDelete(ctx, rt), "DELETE /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1")
|
||||
assertDryRunContains(t, dryRunFieldSearchOptions(ctx, rt), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1/options", "offset=3", "limit=30", "query=open")
|
||||
|
||||
autoNumberRT := newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "app_x",
|
||||
"table-id": "tbl_1",
|
||||
"field-id": "fld_1",
|
||||
"json": `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
autoNumberDR := dryRunFieldUpdate(ctx, autoNumberRT)
|
||||
assertDryRunContains(t, autoNumberDR, "PUT /open-apis/base/v3/bases/app_x/tables/tbl_1/fields/fld_1", `"name":"编号"`, `"type":"auto_number"`, `"rules":[`, `"length":4`)
|
||||
if out := autoNumberDR.Format(); strings.Contains(out, "auto_serial") || strings.Contains(out, "reformat_existing_records") || strings.Contains(out, "/open-apis/bitable/v1/") {
|
||||
t.Fatalf("auto_number dry-run must stay on v3 field JSON, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunRecordOps(t *testing.T) {
|
||||
@@ -117,7 +133,7 @@ func TestDryRunRecordOps(t *testing.T) {
|
||||
)
|
||||
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
|
||||
|
||||
listFieldNamesAliasRT := newBaseTestRuntimeWithSlices(
|
||||
listFieldNamesAliasRT := newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
|
||||
map[string][]string{"field-names": {"Name", "Age"}},
|
||||
nil,
|
||||
|
||||
@@ -81,6 +81,37 @@ func runShortcutWithAuthTypes(t *testing.T, shortcut common.Shortcut, authTypes
|
||||
return parent.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func assertInvalidArgumentValidation(t *testing.T, err error, wantParam string, wantParams []string, messageContains string) {
|
||||
t.Helper()
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid-argument validation error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected invalid-argument validation problem, got %T %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected ValidationError, got %T %v", err, err)
|
||||
}
|
||||
if validationErr.Param != wantParam {
|
||||
t.Fatalf("param=%q, want %q", validationErr.Param, wantParam)
|
||||
}
|
||||
if wantParams != nil {
|
||||
if len(validationErr.Params) != len(wantParams) {
|
||||
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
|
||||
}
|
||||
for i, want := range wantParams {
|
||||
if validationErr.Params[i].Name != want {
|
||||
t.Fatalf("params=%#v, want %v", validationErr.Params, wantParams)
|
||||
}
|
||||
}
|
||||
}
|
||||
if messageContains != "" && !strings.Contains(err.Error(), messageContains) {
|
||||
t.Fatalf("err=%v, want message containing %q", err, messageContains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseWorkspaceExecuteCreate(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stderr, _ := factory.IOStreams.ErrOut.(*bytes.Buffer)
|
||||
@@ -818,8 +849,189 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"fld_x"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldUpdateResultAlwaysRecommendsReadback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
field interface{}
|
||||
submitted map[string]interface{}
|
||||
hintContains []string
|
||||
}{
|
||||
{
|
||||
name: "direct complex server type overrides simple submitted type",
|
||||
field: map[string]interface{}{"type": "auto_number"},
|
||||
submitted: map[string]interface{}{"type": "number"},
|
||||
hintContains: []string{`submitted type "number"`, `server returned type "auto_number"`},
|
||||
},
|
||||
{
|
||||
name: "nested simple server type still recommends readback",
|
||||
field: map[string]interface{}{"field": map[string]interface{}{"type": "number"}},
|
||||
submitted: map[string]interface{}{"type": "auto_number"},
|
||||
hintContains: []string{`submitted type "auto_number"`, `server returned type "number"`},
|
||||
},
|
||||
{
|
||||
name: "submitted simple type still recommends readback when response omits type",
|
||||
field: map[string]interface{}{"id": "fld_x"},
|
||||
submitted: map[string]interface{}{"type": "text"},
|
||||
hintContains: []string{`type "text"`, "cannot determine the previous type"},
|
||||
},
|
||||
{
|
||||
name: "missing type is conservative",
|
||||
field: map[string]interface{}{"id": "fld_x"},
|
||||
submitted: map[string]interface{}{"name": "Amount"},
|
||||
hintContains: []string{"unknown or uncommon field type", "+field-get"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := fieldUpdateResult(map[string]interface{}{"field": tc.field, "updated": true}, tc.submitted)
|
||||
if got["field_get_recommended"] != true || got["next_step"] != "field_get" {
|
||||
t.Fatalf("result=%#v, want readback recommendation", got)
|
||||
}
|
||||
hint, _ := got["verification_hint"].(string)
|
||||
for _, want := range tc.hintContains {
|
||||
if !strings.Contains(hint, want) {
|
||||
t.Fatalf("verification_hint=%q, want substring %q", hint, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldExecuteUpdateNoopReturnsAPIError(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 800070003,
|
||||
"msg": "no operation produced",
|
||||
},
|
||||
})
|
||||
err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `{"name":"Amount","type":"number"}`, "--yes"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected the API no-op response to surface as an error, got nil")
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected a typed API error, got %T %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeUnknown || p.Code != 800070003 {
|
||||
t.Fatalf("category/subtype/code=%s/%s/%d", p.Category, p.Subtype, p.Code)
|
||||
}
|
||||
var apiErr *errs.APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T %v", err, err)
|
||||
}
|
||||
if got := stdout.String(); strings.TrimSpace(got) != "" {
|
||||
t.Fatalf("no success envelope should be emitted on a no-op API error:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldExecuteUpdateAutoNumberUsesV3FieldJSON(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"field": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}`
|
||||
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
gotBody := string(stub.CapturedBody)
|
||||
for _, want := range []string{
|
||||
`"name":"编号"`,
|
||||
`"type":"auto_number"`,
|
||||
`"rules":[`,
|
||||
`"date_format":"yyyyMM"`,
|
||||
`"length":4`,
|
||||
} {
|
||||
if !strings.Contains(gotBody, want) {
|
||||
t.Fatalf("request body missing %q:\n%s", want, gotBody)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"auto_serial", "reformat_existing_records", `"type":1005`} {
|
||||
if strings.Contains(gotBody, forbidden) {
|
||||
t.Fatalf("request body must not contain v1 field %q:\n%s", forbidden, gotBody)
|
||||
}
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"updated": true`, `"fld_x"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{`"reformat_existing_records"`} {
|
||||
if strings.Contains(got, forbidden) {
|
||||
t.Fatalf("stdout must not expose %q:\n%s", forbidden, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldExecuteUpdateDoesNotRejectExtraJSONKeys(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
stub := &httpmock.Stub{
|
||||
Method: "PUT",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_x",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_x", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
// Unknown v3 keys are forwarded unchanged; the server remains the source of
|
||||
// truth for whether a field-update property is supported.
|
||||
jsonBody := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"incremental_number","length":4}]},"reformat_existing_records":true}`
|
||||
if err := runShortcut(t, BaseFieldUpdate, []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", jsonBody, "--yes"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if gotBody := string(stub.CapturedBody); !strings.Contains(gotBody, `"reformat_existing_records":true`) {
|
||||
t.Fatalf("request body must preserve unknown v3 key:\n%s", gotBody)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"updated": true`) {
|
||||
t.Fatalf("expected successful update, got: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseFieldValidateAllowsRatingMaxAboveLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
runtime *common.RuntimeContext
|
||||
}{
|
||||
{
|
||||
name: "create",
|
||||
shortcut: BaseFieldCreate,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
|
||||
},
|
||||
{
|
||||
name: "update",
|
||||
shortcut: BaseFieldUpdate,
|
||||
runtime: newBaseTestRuntime(map[string]string{"base-token": "app_x", "table-id": "tbl_x", "field-id": "fld_x", "json": `{"name":"评分","type":"number","style":{"type":"rating","icon":"star","min":0,"max":20}}`}, nil, nil),
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := tc.shortcut.Validate(ctx, tc.runtime); err != nil {
|
||||
t.Fatalf("rating max above 10 should not be blocked by CLI validation: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1091,8 +1303,32 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
|
||||
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"Status","type":"text"}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"created": true`) || !strings.Contains(got, `"fld_new"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"created": true`, `"fld_new"`, `"field_get_recommended": false`, `"next_step": "done"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create generated field recommends readback", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_auto", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"name":"编号","type":"auto_number"}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
for _, want := range []string{`"created": true`, `"fld_auto"`, `"field_get_recommended": true`, `"next_step": "field_get"`, `"verification_hint"`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("stdout missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1139,11 +1375,58 @@ func TestBaseFieldExecuteCRUD(t *testing.T) {
|
||||
if len(fields) != 2 {
|
||||
t.Fatalf("fields len=%d output=%#v", len(fields), data)
|
||||
}
|
||||
if data["field_get_recommended"] != false || data["next_step"] != "done" || data["verification_hint"] == nil {
|
||||
t.Fatalf("simple batch create must carry field_get_recommended:false + next_step:done + verification_hint: %#v", data)
|
||||
}
|
||||
if !strings.Contains(string(firstStub.CapturedBody), `"name":"A"`) || !strings.Contains(string(secondStub.CapturedBody), `"name":"B"`) {
|
||||
t.Fatalf("unexpected request bodies: %s / %s", firstStub.CapturedBody, secondStub.CapturedBody)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("create array with generated field recommends readback", func(t *testing.T) {
|
||||
oldDelay := fieldCreateBatchDelay
|
||||
fieldCreateBatchDelay = 0
|
||||
t.Cleanup(func() { fieldCreateBatchDelay = oldDelay })
|
||||
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"name":"Title"`)
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_title", "name": "Title", "type": "text"},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields",
|
||||
BodyFilter: func(body []byte) bool {
|
||||
return strings.Contains(string(body), `"name":"编号"`)
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"id": "fld_no", "name": "编号", "type": "auto_number"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runShortcut(t, BaseFieldCreate, []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[{"name":"Title","type":"text"},{"name":"编号","type":"auto_number"}]`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data := decodeBaseEnvelope(t, stdout)
|
||||
if data["created"] != true || data["total"] != float64(2) {
|
||||
t.Fatalf("unexpected output: %#v", data)
|
||||
}
|
||||
if _, ok := data["fields"].([]interface{}); !ok {
|
||||
t.Fatalf("batch create must keep fields array: %#v", data)
|
||||
}
|
||||
if data["field_get_recommended"] != true || data["next_step"] != "field_get" || data["verification_hint"] == nil {
|
||||
t.Fatalf("batch with auto_number must recommend readback: %#v", data)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1318,6 +1601,32 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list field names alias preserves quoted commas and at-sign names", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=A%2CB&field_id=%40Owner&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"A,B", "@Owner"},
|
||||
"record_id_list": []interface{}{"rec_alias_special"},
|
||||
"data": []interface{}{[]interface{}{"value-1", "value-2"}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{
|
||||
"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1",
|
||||
"--field-names", `"A,B",@Owner`, "--format", "json",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_alias_special"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list json format", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -1614,28 +1923,162 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list legacy fields flag rejected", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
|
||||
t.Run("list fields alias accepts JSON array projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_fields"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--fields", `["Name","Age"]`, "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list field ids and field names alias are mutually exclusive", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "Name", "--field-names", "Age"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "--field-id and --field-names are mutually exclusive") {
|
||||
t.Run("list field names alias accepts repeated projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_fields"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
"total": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-names", "Name", "--field-names", "Age", "--format", "json"}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"rec_fields"`) || !strings.Contains(got, `"Alice"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
|
||||
t.Run("list projection aliases report only supplied ambiguous inputs", func(t *testing.T) {
|
||||
baseArgs := []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantParam string
|
||||
wantParams []string
|
||||
}{
|
||||
{name: "canonical and fields alias", args: []string{"--field-id", "Name", "--fields", `["Age"]`}, wantParam: "--field-id", wantParams: []string{"--field-id", "--fields"}},
|
||||
{name: "canonical and field names alias", args: []string{"--field-id", "Name", "--field-names", "Age"}, wantParam: "--field-id", wantParams: []string{"--field-id", "--field-names"}},
|
||||
{name: "compatibility aliases", args: []string{"--fields", `["Name"]`, "--field-names", "Age"}, wantParam: "--fields", wantParams: []string{"--fields", "--field-names"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := append(append([]string{}, baseArgs...), tc.args...)
|
||||
err := runShortcut(t, BaseRecordList, args, factory, stdout)
|
||||
assertInvalidArgumentValidation(t, err, tc.wantParam, tc.wantParams, "mutually exclusive")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || validationErr.Hint != "Use only --field-id for projection." {
|
||||
t.Fatalf("hint=%q, want canonical projection guidance", validationErr.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search json conflict reports each supplied projection parameter", func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --fields") {
|
||||
err := runShortcut(t, BaseRecordSearch, []string{
|
||||
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--json", `{"keyword":"Alice","search_fields":["Name"]}`,
|
||||
"--field-names", "Age",
|
||||
}, factory, stdout)
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-names"}, "mutually exclusive")
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) || !strings.Contains(validationErr.Hint, "inside --json") {
|
||||
t.Fatalf("hint=%q, want JSON-body guidance", validationErr.Hint)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("list canonical and alias projections reject duplicates consistently", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
args []string
|
||||
param string
|
||||
}{
|
||||
{name: "canonical", args: []string{"--field-id", "Cost--USD", "--field-id", "Cost--USD"}, param: "--field-id"},
|
||||
{name: "fields alias", args: []string{"--fields", `["Cost--USD","Cost--USD"]`}, param: "--fields"},
|
||||
{name: "field names alias", args: []string{"--field-names", "Cost--USD", "--field-names", "Cost--USD"}, param: "--field-names"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
factory, stdout, _ := newExecuteFactory(t)
|
||||
args := append([]string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x"}, tc.args...)
|
||||
err := runShortcut(t, BaseRecordList, args, factory, stdout)
|
||||
assertInvalidArgumentValidation(t, err, tc.param, []string{tc.param}, "duplicate field id")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("search fields alias accepts JSON array projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
searchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/search",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"record_id_list": []interface{}{"rec_search"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(searchStub)
|
||||
if err := runShortcut(t, BaseRecordSearch, []string{
|
||||
"+record-search", "--base-token", "app_x", "--table-id", "tbl_x",
|
||||
"--keyword", "Alice", "--search-field", "Name", "--fields", `["Name","Age"]`, "--format", "json",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if body := string(searchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
||||
t.Fatalf("captured body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get field names alias accepts repeated projection", func(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
batchStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_get",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"fields": []interface{}{"Name", "Age"},
|
||||
"data": []interface{}{[]interface{}{"Alice", 18}},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(batchStub)
|
||||
if err := runShortcut(t, BaseRecordGet, []string{
|
||||
"+record-get", "--base-token", "app_x", "--table-id", "tbl_x", "--record-id", "rec_1",
|
||||
"--field-names", "Name", "--field-names", "Age", "--format", "json",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if body := string(batchStub.CapturedBody); !strings.Contains(body, `"select_fields":["Name","Age"]`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("get", func(t *testing.T) {
|
||||
@@ -2014,16 +2457,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"has_more": false,
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
"update": map[string]interface{}{"Status": "Done"},
|
||||
"ignored_fields": []interface{}{"Formula"},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Status":"Done"}}`}, factory, stdout); err != nil {
|
||||
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"update_records":{"rec_1":{"Status":["Done"]}}}`}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"update"`) || !strings.Contains(got, `"Done"`) {
|
||||
if got := stdout.String(); !strings.Contains(got, `"ignored_fields"`) || !strings.Contains(got, `"Formula"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
})
|
||||
@@ -2035,20 +2476,16 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
|
||||
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/batch_update",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"record_id_list": []interface{}{"rec_1"},
|
||||
},
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(updateStub)
|
||||
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `{"record_id_list":["rec_1"],"patch":{"Name":"Alice","Status":"Done"}}`}, factory, stdout); err != nil {
|
||||
input := `{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`
|
||||
if err := runShortcut(t, BaseRecordBatchUpdate, []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", input}, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"rec_1"`) {
|
||||
t.Fatalf("stdout=%s", got)
|
||||
}
|
||||
body := string(updateStub.CapturedBody)
|
||||
if !strings.Contains(body, `"record_id_list":["rec_1"]`) || !strings.Contains(body, `"patch":{"Name":"Alice","Status":"Done"}`) {
|
||||
if !strings.Contains(body, `"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}`) {
|
||||
t.Fatalf("request body=%s", body)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -28,23 +28,16 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, stringArrayFlags, nil, boolFlags, intFlags)
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithSlices(stringFlags map[string]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, nil, stringSliceFlags, boolFlags, intFlags)
|
||||
}
|
||||
|
||||
func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, stringArrayFlags map[string][]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for name := range stringFlags {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
for name := range stringArrayFlags {
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
}
|
||||
for name := range stringSliceFlags {
|
||||
cmd.Flags().StringSlice(name, nil, "")
|
||||
if name == "field-names" {
|
||||
cmd.Flags().StringSlice(name, nil, "")
|
||||
} else {
|
||||
cmd.Flags().StringArray(name, nil, "")
|
||||
}
|
||||
}
|
||||
for name := range boolFlags {
|
||||
cmd.Flags().Bool(name, false, "")
|
||||
@@ -61,11 +54,6 @@ func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, string
|
||||
_ = cmd.Flags().Set(name, value)
|
||||
}
|
||||
}
|
||||
for name, values := range stringSliceFlags {
|
||||
for _, value := range values {
|
||||
_ = cmd.Flags().Set(name, value)
|
||||
}
|
||||
}
|
||||
for name, value := range boolFlags {
|
||||
if value {
|
||||
_ = cmd.Flags().Set(name, "true")
|
||||
@@ -477,6 +465,40 @@ func TestBaseLimitPageSizeAliasIsHidden(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordProjectionAliasesAreHidden(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
}{
|
||||
{name: "record list", shortcut: BaseRecordList},
|
||||
{name: "record search", shortcut: BaseRecordSearch},
|
||||
{name: "record get", shortcut: BaseRecordGet},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parent := &cobra.Command{Use: "base"}
|
||||
tt.shortcut.Mount(parent, &cmdutil.Factory{})
|
||||
cmd := parent.Commands()[0]
|
||||
|
||||
primary := cmd.Flags().Lookup("field-id")
|
||||
if primary == nil || primary.Hidden {
|
||||
t.Fatalf("public projection flag --field-id missing or hidden: %#v", primary)
|
||||
}
|
||||
help := cmd.Flags().FlagUsages()
|
||||
for _, aliasName := range []string{"fields", "field-names"} {
|
||||
alias := cmd.Flags().Lookup(aliasName)
|
||||
if alias == nil || !alias.Hidden {
|
||||
t.Fatalf("projection alias --%s should exist and be hidden: %#v", aliasName, alias)
|
||||
}
|
||||
if strings.Contains(help, "--"+aliasName) {
|
||||
t.Fatalf("help should not include hidden --%s:\n%s", aliasName, help)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -786,7 +808,8 @@ func TestBaseJSONExamplesLiveInFlagDescriptions(t *testing.T) {
|
||||
name: "record batch update json",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
wantHelp: []string{
|
||||
`batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`,
|
||||
"update_records maps each record ID to its field map",
|
||||
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -822,6 +845,10 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
"does not auto-upsert by business key",
|
||||
"use +field-list to confirm real writable fields",
|
||||
"do not write system fields, formula, lookup, or attachment fields",
|
||||
"Sub-record/child-record path",
|
||||
"set that link field to a parent record reference array",
|
||||
`{"Parent Link":[{"id":"rec_xxx"}]}`,
|
||||
"do not look for parent_record_id or a separate child-record API",
|
||||
"CellValue happy path: text/phone/url",
|
||||
"select -> \"Todo\"",
|
||||
"multi-select -> [\"Tag A\",\"Tag B\"]",
|
||||
@@ -854,9 +881,11 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
|
||||
name: "record batch update",
|
||||
shortcut: BaseRecordBatchUpdate,
|
||||
wantTips: []string{
|
||||
"Happy path fields: record_id_list is the target record IDs",
|
||||
"patch is a field map applied unchanged to every target record",
|
||||
"Do not use +record-batch-update for per-row different values",
|
||||
"Happy path field: update_records",
|
||||
"update_records maps each record ID to its own field map",
|
||||
`{"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`,
|
||||
"contains only optional ignored_fields",
|
||||
"does not check whether record IDs exist",
|
||||
"use +field-list to confirm real writable fields",
|
||||
"Batch update supports max 200 records per call",
|
||||
"CellValue happy path: text/phone/url",
|
||||
@@ -970,11 +999,17 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
t.Fatalf("flag help missing %q:\n%s", want, help)
|
||||
}
|
||||
}
|
||||
if strings.Contains(help, "reformat-existing-records") {
|
||||
t.Fatalf("+field-update must not expose a --reformat-existing-records flag:\n%s", help)
|
||||
}
|
||||
|
||||
tips := strings.Join(cmdutil.GetTips(cmd), "\n")
|
||||
wantTips := []string{
|
||||
`lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`"type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]`,
|
||||
`Example auto_number update: lark-cli base +field-update`,
|
||||
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers`,
|
||||
"just submit the target field definition and do not add extra low-level parameters",
|
||||
"full field-definition PUT semantics",
|
||||
"Read the current field first with +field-get",
|
||||
"Type conversion is allowlist-based",
|
||||
@@ -987,6 +1022,9 @@ func TestBaseFieldUpdateHelpGuidesAgents(t *testing.T) {
|
||||
t.Fatalf("tips missing %q:\n%s", want, tips)
|
||||
}
|
||||
}
|
||||
if strings.Contains(tips, "--reformat-existing-records") {
|
||||
t.Fatalf("+field-update tips must not ask agents to pass --reformat-existing-records:\n%s", tips)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseAttachmentHelpGuidesAgents(t *testing.T) {
|
||||
@@ -1109,6 +1147,10 @@ func TestBaseFieldValidate(t *testing.T) {
|
||||
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": `{"name":"f1","type":"formula"}`}, map[string]bool{"i-have-read-guide": true}, nil)); err != nil {
|
||||
t.Fatalf("formula update validate err=%v", err)
|
||||
}
|
||||
autoNumberJSON := `{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"incremental_number","length":4}]}}`
|
||||
if err := BaseFieldUpdate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "field-id": "fld_1", "json": autoNumberJSON}, nil, nil)); err != nil {
|
||||
t.Fatalf("auto number update validate err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseTableValidate(t *testing.T) {
|
||||
@@ -1230,13 +1272,89 @@ func TestBaseRecordValidate(t *testing.T) {
|
||||
)); err != nil {
|
||||
t.Fatalf("record search json with sort-json validate err=%v", err)
|
||||
}
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "keyword": "Bob"},
|
||||
nil,
|
||||
nil,
|
||||
)); err == nil || !strings.Contains(err.Error(), "--json is mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--keyword"}, "mutually exclusive")
|
||||
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"keyword":"Alice","search_fields":["Name"]}`, "fields": "Name"},
|
||||
map[string][]string{"field-id": {"fld_name"}},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json", "--field-id", "--fields"}, "mutually exclusive")
|
||||
}
|
||||
|
||||
func TestBaseRecordSearchProjectionLimit(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fields := make([]string, 51)
|
||||
for i := range fields {
|
||||
fields[i] = "Field " + strconv.Itoa(i+1)
|
||||
}
|
||||
|
||||
if err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}, "field-id": fields[:50]},
|
||||
nil,
|
||||
nil,
|
||||
)); err != nil {
|
||||
t.Fatalf("50 projection fields should be accepted: %v", err)
|
||||
}
|
||||
|
||||
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntimeWithArrays(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "keyword": "Alice"},
|
||||
map[string][]string{"search-field": {"Name"}, "field-id": fields},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--field-id", []string{"--field-id"}, "maximum limit of 50")
|
||||
|
||||
body, marshalErr := json.Marshal(map[string]interface{}{
|
||||
"keyword": "Alice",
|
||||
"search_fields": []string{"Name"},
|
||||
"select_fields": fields,
|
||||
})
|
||||
if marshalErr != nil {
|
||||
t.Fatalf("marshal search body: %v", marshalErr)
|
||||
}
|
||||
err = BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{"base-token": "b", "table-id": "tbl_1", "json": string(body)},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "maximum limit of 50")
|
||||
}
|
||||
|
||||
func TestRecordSearchJSONNullProjectionIsOmitted(t *testing.T) {
|
||||
runtime := newBaseTestRuntime(map[string]string{
|
||||
"json": `{"keyword":"Alice","search_fields":["Name"],"select_fields":null,"sort":{"sort_config":[{"field":"Updated","desc":true}]}}`,
|
||||
}, nil, nil)
|
||||
body, err := recordSearchJSONBody(runtime)
|
||||
if err != nil {
|
||||
t.Fatalf("recordSearchJSONBody() error = %v", err)
|
||||
}
|
||||
if _, exists := body["select_fields"]; exists {
|
||||
t.Fatalf("select_fields:null must normalize to omitted, body=%#v", body)
|
||||
}
|
||||
if sortConfig, ok := body["sort"].([]interface{}); !ok || len(sortConfig) != 1 {
|
||||
t.Fatalf("sort normalization must continue after omitting null select_fields, body=%#v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseRecordSearchJSONProjectionParamIgnoresFlagLikeFieldNames(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
err := BaseRecordSearch.Validate(ctx, newBaseTestRuntime(
|
||||
map[string]string{
|
||||
"base-token": "b",
|
||||
"table-id": "tbl_1",
|
||||
"json": `{"keyword":"cost","search_fields":["Name"],"select_fields":["Cost--USD","Cost--USD"]}`,
|
||||
},
|
||||
nil,
|
||||
nil,
|
||||
))
|
||||
assertInvalidArgumentValidation(t, err, "--json", []string{"--json"}, "duplicate field id")
|
||||
}
|
||||
|
||||
func TestBasePaginationValidationRejectsOutOfRange(t *testing.T) {
|
||||
|
||||
@@ -5,6 +5,7 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -36,7 +37,10 @@ func dryRunFieldGet(_ context.Context, runtime *common.RuntimeContext) *common.D
|
||||
|
||||
func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
bodies, _ := parseFieldCreateBodies(pc, runtime.Str("json"))
|
||||
bodies, err := parseFieldCreateBodies(pc, runtime.Str("json"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
||||
}
|
||||
dr := common.NewDryRunAPI().
|
||||
Set("base_token", runtime.Str("base-token")).
|
||||
Set("table_id", baseTableID(runtime))
|
||||
@@ -48,7 +52,10 @@ func dryRunFieldCreate(_ context.Context, runtime *common.RuntimeContext) *commo
|
||||
|
||||
func dryRunFieldUpdate(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
pc := newParseCtx(runtime)
|
||||
body, _ := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
body, err := parseJSONObject(pc, runtime.Str("json"), "json")
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Desc(fmt.Sprintf("dry-run validation failed: %v", err))
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
PUT("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
|
||||
Body(body).
|
||||
@@ -166,10 +173,10 @@ func executeFieldCreate(runtime *common.RuntimeContext) error {
|
||||
fields = append(fields, data)
|
||||
}
|
||||
if len(fields) == 1 {
|
||||
runtime.Out(map[string]interface{}{"field": fields[0], "created": true}, nil)
|
||||
runtime.Out(fieldCreateResult(map[string]interface{}{"field": fields[0], "created": true}, bodies[0]), nil)
|
||||
return nil
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, nil)
|
||||
runtime.Out(fieldCreateBatchResult(map[string]interface{}{"fields": fields, "created": true, "total": len(fields)}, bodies), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -197,10 +204,101 @@ func executeFieldUpdate(runtime *common.RuntimeContext) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.Out(map[string]interface{}{"field": data, "updated": true}, nil)
|
||||
runtime.Out(fieldUpdateResult(map[string]interface{}{"field": data, "updated": true}, body), nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
func fieldCreateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
|
||||
readbackRecommended, reason := fieldWriteReadbackRecommendation(submitted, "create")
|
||||
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
|
||||
}
|
||||
|
||||
// fieldCreateBatchResult attaches the same top-level readback contract to a
|
||||
// multi-field create. It recommends +field-get when any submitted field is a
|
||||
// computed/linked/generated (or unknown) type, so agents know when to verify
|
||||
// server state without breaking the existing fields/total structure.
|
||||
func fieldCreateBatchResult(result map[string]interface{}, submitted []map[string]interface{}) map[string]interface{} {
|
||||
recommend := false
|
||||
reason := "simple fields created successfully; use +field-get only when extra properties or explicit verification are needed"
|
||||
for _, body := range submitted {
|
||||
if rec, r := fieldWriteReadbackRecommendation(body, "create"); rec {
|
||||
recommend = true
|
||||
reason = r
|
||||
break
|
||||
}
|
||||
}
|
||||
return attachFieldReadbackRecommendation(result, recommend, reason)
|
||||
}
|
||||
|
||||
func fieldUpdateResult(result map[string]interface{}, submitted map[string]interface{}) map[string]interface{} {
|
||||
returnedType := normalizeFieldType(fieldResultType(result["field"]))
|
||||
submittedType := normalizeFieldType(common.GetString(submitted, "type"))
|
||||
readbackRecommended, reason := fieldUpdateReadbackRecommendation(returnedType, submittedType)
|
||||
return attachFieldReadbackRecommendation(result, readbackRecommended, reason)
|
||||
}
|
||||
|
||||
func fieldUpdateReadbackRecommendation(returnedType, submittedType string) (bool, string) {
|
||||
if returnedType != "" && submittedType != "" && returnedType != submittedType {
|
||||
return true, fmt.Sprintf("field update submitted type %q but the server returned type %q; run +field-get and verify record values before declaring completion", submittedType, returnedType)
|
||||
}
|
||||
|
||||
fieldType := returnedType
|
||||
if fieldType == "" {
|
||||
fieldType = submittedType
|
||||
}
|
||||
if recommended, reason := fieldTypeReadbackRecommendation(fieldType, "update"); recommended {
|
||||
return true, reason + "; sample record values when generated, computed, or converted values are in scope"
|
||||
}
|
||||
return true, fmt.Sprintf("field update request succeeded for type %q, but +field-update cannot determine the previous type; run +field-get and sample record values if the type changed before declaring completion", fieldType)
|
||||
}
|
||||
|
||||
func attachFieldReadbackRecommendation(result map[string]interface{}, readbackRecommended bool, reason string) map[string]interface{} {
|
||||
result["field_get_recommended"] = readbackRecommended
|
||||
result["verification_hint"] = reason
|
||||
if readbackRecommended {
|
||||
result["next_step"] = "field_get"
|
||||
} else {
|
||||
result["next_step"] = "done"
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fieldWriteReadbackRecommendation(submitted map[string]interface{}, operation string) (bool, string) {
|
||||
fieldType := normalizeFieldType(common.GetString(submitted, "type"))
|
||||
return fieldTypeReadbackRecommendation(fieldType, operation)
|
||||
}
|
||||
|
||||
func fieldTypeReadbackRecommendation(fieldType, operation string) (bool, string) {
|
||||
fieldType = normalizeFieldType(fieldType)
|
||||
switch fieldType {
|
||||
case "formula", "lookup", "auto_number", "link":
|
||||
return true, fmt.Sprintf("computed, linked, or generated field %s should be verified with +field-get before declaring completion", operation)
|
||||
case "text", "number", "select", "datetime", "checkbox", "user", "group_chat", "attachment", "location":
|
||||
return false, fmt.Sprintf("simple field %s returned successfully; use +field-get only when extra properties or explicit verification are needed", operation)
|
||||
default:
|
||||
return true, "unknown or uncommon field type; run +field-get to avoid assuming the submitted JSON fully describes server state"
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeFieldType(fieldType string) string {
|
||||
return strings.ToLower(strings.TrimSpace(fieldType))
|
||||
}
|
||||
|
||||
func fieldResultType(value interface{}) string {
|
||||
field, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
if fieldType := strings.ToLower(strings.TrimSpace(common.GetString(field, "type"))); fieldType != "" {
|
||||
return fieldType
|
||||
}
|
||||
nested, ok := field["field"].(map[string]interface{})
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(common.GetString(nested, "type")))
|
||||
}
|
||||
|
||||
func executeFieldDelete(runtime *common.RuntimeContext) error {
|
||||
baseToken := runtime.Str("base-token")
|
||||
tableIDValue := baseTableID(runtime)
|
||||
|
||||
@@ -27,7 +27,9 @@ var BaseFieldUpdate = common.Shortcut{
|
||||
baseHighRiskYesTip,
|
||||
`Example text: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"text"}' --yes`,
|
||||
`Example select: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "Status" --json '{"name":"Status","type":"select","multiple":false,"options":[{"name":"Todo"},{"name":"Done"}]}' --yes`,
|
||||
`Example auto_number update: lark-cli base +field-update --base-token <base_token> --table-id <table_id> --field-id "编号" --json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' --yes`,
|
||||
"Update uses full field-definition PUT semantics. Read the current field first with +field-get, then send the target state.",
|
||||
`When --json.type is "auto_number", updating the numbering rules also reapplies them to existing numbers; just submit the target field definition and do not add extra low-level parameters.`,
|
||||
"Type conversion is allowlist-based: only use CLI for safe conversions; otherwise migrate through a new field, or ask the user to finish high-risk conversions in the web UI.",
|
||||
"Formula and lookup updates require reading the corresponding guide first.",
|
||||
"Agent hint: use the lark-base skill's field-update guide for JSON shape, type-conversion rules, and limits.",
|
||||
|
||||
@@ -238,14 +238,14 @@ func TestRecordSelectionHelpers(t *testing.T) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
fields, err = resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{"Name"}})
|
||||
fields, err = resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Name"}})
|
||||
if err != nil || !reflect.DeepEqual(fields, []string{"Name"}) {
|
||||
t.Fatalf("fields=%v err=%v", fields, err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields([]string{"Name"}, map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
if _, err := resolveRecordGetSelectFields([]string{"Name"}, "--field-id", map[string]interface{}{"select_fields": []interface{}{"Age"}}); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if _, err := resolveRecordGetSelectFields(nil, map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
if _, err := resolveRecordGetSelectFields(nil, "--field-id", map[string]interface{}{"select_fields": []interface{}{}}); err == nil || !strings.Contains(err.Error(), "must not be empty") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -12,18 +12,19 @@ import (
|
||||
var BaseRecordBatchUpdate = common.Shortcut{
|
||||
Service: "base",
|
||||
Command: "+record-batch-update",
|
||||
Description: "Batch update records",
|
||||
Description: "Batch update records with record-specific fields",
|
||||
Risk: "write",
|
||||
Scopes: []string{"base:record:update"},
|
||||
AuthTypes: authTypes(),
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "json", Desc: `batch update JSON object, e.g. {"record_id_list":["rec_xxx"],"patch":{"Status":"Done"}}; same patch applies to all records`, Required: true},
|
||||
{Name: "json", Desc: `batch update JSON object; update_records maps each record ID to its field map, e.g. {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}`, Required: true},
|
||||
},
|
||||
Tips: append([]string{
|
||||
"Happy path fields: record_id_list is the target record IDs; patch is a field map applied unchanged to every target record.",
|
||||
"Do not use +record-batch-update for per-row different values; call +record-upsert per record or use another supported flow.",
|
||||
"Happy path field: update_records maps each record ID to its own field map.",
|
||||
`Example: {"update_records":{"recA":{"Status":["Done"]},"recB":{"Score":20}}}.`,
|
||||
"The response contains only optional ignored_fields and does not check whether record IDs exist; read records back when confirmation is required.",
|
||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
||||
"Batch update supports max 200 records per call; use the record-batch-update guide for command limits and edge cases.",
|
||||
}, recordCellValueHappyPathTips...),
|
||||
|
||||
@@ -21,7 +21,9 @@ var BaseRecordGet = common.Shortcut{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
{Name: "record-id", Type: "string_array", Desc: "record ID (repeatable)"},
|
||||
{Name: "field-id", Type: "string_array", Desc: "field ID or name to project; repeat to keep only needed columns"},
|
||||
recordProjectionFieldFlag("field ID or name to project; repeat to keep only needed columns"),
|
||||
recordProjectionAliasFlag("fields"),
|
||||
recordProjectionAliasFlag("field-names"),
|
||||
{Name: "json", Desc: `JSON object with record_id_list, e.g. {"record_id_list":["rec_xxx"]}`},
|
||||
recordReadFormatFlag(),
|
||||
},
|
||||
|
||||
@@ -20,8 +20,9 @@ var BaseRecordList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
baseTokenFlag(true),
|
||||
tableRefFlag(true),
|
||||
recordListFieldRefFlag(),
|
||||
recordListFieldNamesAliasFlag(),
|
||||
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
|
||||
recordProjectionAliasFlag("fields"),
|
||||
recordProjectionAliasFlag("field-names"),
|
||||
recordListViewRefFlag(),
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
@@ -44,9 +45,6 @@ var BaseRecordList = common.Shortcut{
|
||||
"Use --field-id repeatedly to keep output small and aligned with the task.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if err := validateRecordListFieldAlias(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRecordReadFormat(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -61,6 +59,9 @@ var BaseRecordList = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := recordProjectionFields(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
},
|
||||
DryRun: dryRunRecordList,
|
||||
@@ -72,22 +73,6 @@ var BaseRecordList = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func recordListFieldRefFlag() common.Flag {
|
||||
flag := fieldRefFlag(false)
|
||||
flag.Type = "string_array"
|
||||
flag.Desc = "field ID or name to include; repeat to project only needed fields"
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordListFieldNamesAliasFlag() common.Flag {
|
||||
return common.Flag{
|
||||
Name: "field-names",
|
||||
Type: "string_slice",
|
||||
Desc: "hidden alias for --field-id; accepts comma-separated field names",
|
||||
Hidden: true,
|
||||
}
|
||||
}
|
||||
|
||||
func recordListViewRefFlag() common.Flag {
|
||||
flag := viewRefFlag(false)
|
||||
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
|
||||
@@ -102,10 +87,3 @@ func recordReadFormatFlag() common.Flag {
|
||||
Desc: "output format: markdown (default) | json",
|
||||
}
|
||||
}
|
||||
|
||||
func validateRecordListFieldAlias(runtime *common.RuntimeContext) error {
|
||||
if runtime.Changed("field-id") && runtime.Changed("field-names") {
|
||||
return baseFlagErrorf("--field-id and --field-names are mutually exclusive; use --field-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,15 +5,18 @@ package base
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const maxRecordSelectionCount = 200
|
||||
const maxBatchGetSelectFieldCount = 100
|
||||
const maxRecordSearchSelectFieldCount = 50
|
||||
|
||||
var recordCellValueHappyPathTips = []string{
|
||||
`CellValue happy path: text/phone/url -> "text"; number/currency/percent/rating -> 12.5; select -> "Todo"; multi-select -> ["Tag A","Tag B"]; datetime -> "2026-03-24 10:00:00"; checkbox -> true/false.`,
|
||||
@@ -46,7 +49,6 @@ func validateRecordSelection(runtime *common.RuntimeContext) error {
|
||||
|
||||
func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, error) {
|
||||
recordIDs := runtime.StrArray("record-id")
|
||||
fieldIDs := runtime.StrArray("field-id")
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if len(recordIDs) > 0 && jsonRaw != "" {
|
||||
return recordSelection{}, baseFlagErrorf("--record-id and --json are mutually exclusive")
|
||||
@@ -69,7 +71,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, body)
|
||||
projectionFields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), body)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
@@ -83,7 +89,11 @@ func resolveRecordSelection(runtime *common.RuntimeContext) (recordSelection, er
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(fieldIDs, nil)
|
||||
projectionFields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
selectFields, err := resolveRecordGetSelectFields(projectionFields, recordProjectionParam(runtime), nil)
|
||||
if err != nil {
|
||||
return recordSelection{}, err
|
||||
}
|
||||
@@ -104,20 +114,20 @@ func normalizeRecordIDs(values interface{}) ([]string, error) {
|
||||
})
|
||||
}
|
||||
|
||||
func resolveRecordGetSelectFields(flagFields []string, body map[string]interface{}) ([]string, error) {
|
||||
func resolveRecordGetSelectFields(flagFields []string, projectionParam string, body map[string]interface{}) ([]string, error) {
|
||||
fromFlags, err := normalizeRecordGetSelectFields(flagFields)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, withValidationParam(err, projectionParam)
|
||||
}
|
||||
if body == nil {
|
||||
return fromFlags, nil
|
||||
}
|
||||
rawJSONFields, ok := body["select_fields"]
|
||||
if !ok {
|
||||
if !ok || rawJSONFields == nil {
|
||||
return fromFlags, nil
|
||||
}
|
||||
if len(fromFlags) > 0 {
|
||||
return nil, baseFlagErrorf(`--field-id and --json field "select_fields" are mutually exclusive`)
|
||||
return nil, baseFlagErrorf(`%s and --json field "select_fields" are mutually exclusive`, projectionParam)
|
||||
}
|
||||
items, ok := rawJSONFields.([]interface{})
|
||||
if !ok {
|
||||
@@ -128,18 +138,26 @@ func resolveRecordGetSelectFields(flagFields []string, body map[string]interface
|
||||
}
|
||||
normalized, err := normalizeRecordGetSelectFields(items)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, withValidationParam(err, "--json")
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
func normalizeRecordGetSelectFields(values interface{}) ([]string, error) {
|
||||
return normalizeRecordSelectFields(values, maxBatchGetSelectFieldCount)
|
||||
}
|
||||
|
||||
func normalizeRecordSearchSelectFields(values interface{}) ([]string, error) {
|
||||
return normalizeRecordSelectFields(values, maxRecordSearchSelectFieldCount)
|
||||
}
|
||||
|
||||
func normalizeRecordSelectFields(values interface{}, max int) ([]string, error) {
|
||||
return normalizeStringList(values, stringListNormalizeOptions{
|
||||
typeError: "field selection must be a string array",
|
||||
itemName: "field selection item",
|
||||
duplicateName: "field id",
|
||||
limitName: "field selection",
|
||||
max: maxBatchGetSelectFieldCount,
|
||||
max: max,
|
||||
allowNil: true,
|
||||
allowEmpty: true,
|
||||
})
|
||||
@@ -211,7 +229,11 @@ func dryRunRecordList(_ context.Context, runtime *common.RuntimeContext) *common
|
||||
params := url.Values{}
|
||||
params.Set("offset", strconv.Itoa(offset))
|
||||
params.Set("limit", strconv.Itoa(limit))
|
||||
for _, field := range recordListFields(runtime) {
|
||||
fields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI()
|
||||
}
|
||||
for _, field := range fields {
|
||||
params.Add("field_id", field)
|
||||
}
|
||||
if viewID := runtime.Str("view-id"); viewID != "" {
|
||||
@@ -375,11 +397,121 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func recordListFields(runtime *common.RuntimeContext) []string {
|
||||
if runtime.Changed("field-names") {
|
||||
return runtime.StrSlice("field-names")
|
||||
func recordProjectionFieldFlag(desc string) common.Flag {
|
||||
flag := fieldRefFlag(false)
|
||||
flag.Type = "string_array"
|
||||
flag.Desc = desc
|
||||
return flag
|
||||
}
|
||||
|
||||
func recordProjectionAliasFlag(name string) common.Flag {
|
||||
flagType := "string_array"
|
||||
if name == "field-names" {
|
||||
// Preserve the original compatibility contract: --field-names uses
|
||||
// pflag's CSV parser, including quoted commas, and treats @ literally.
|
||||
flagType = "string_slice"
|
||||
}
|
||||
return runtime.StrArray("field-id")
|
||||
return common.Flag{
|
||||
Name: name,
|
||||
Type: flagType,
|
||||
Desc: "hidden alias for --field-id projection",
|
||||
Hidden: true,
|
||||
}
|
||||
}
|
||||
|
||||
func recordProjectionParam(runtime *common.RuntimeContext) string {
|
||||
switch {
|
||||
case runtime.Changed("fields"):
|
||||
return "--fields"
|
||||
case runtime.Changed("field-names"):
|
||||
return "--field-names"
|
||||
default:
|
||||
return "--field-id"
|
||||
}
|
||||
}
|
||||
|
||||
func withValidationParam(err error, param string) error {
|
||||
if err == nil || param == "" {
|
||||
return err
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
return err
|
||||
}
|
||||
reason := validationErr.Error()
|
||||
// The caller knows which input produced this validation error. Replace any
|
||||
// params inferred from the rendered message: field values such as Cost--USD
|
||||
// must not be mistaken for a --USD flag.
|
||||
validationErr.Param = param
|
||||
validationErr.Params = []errs.InvalidParam{{Name: param, Reason: reason}}
|
||||
return err
|
||||
}
|
||||
|
||||
func recordProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
|
||||
return recordProjectionFieldsWithLimit(runtime, maxBatchGetSelectFieldCount)
|
||||
}
|
||||
|
||||
func recordSearchProjectionFields(runtime *common.RuntimeContext) ([]string, error) {
|
||||
return recordProjectionFieldsWithLimit(runtime, maxRecordSearchSelectFieldCount)
|
||||
}
|
||||
|
||||
func recordProjectionFieldsWithLimit(runtime *common.RuntimeContext, max int) ([]string, error) {
|
||||
fieldIDs := runtime.StrArray("field-id")
|
||||
fieldIDsSet := runtime.Changed("field-id")
|
||||
fieldsSet := runtime.Changed("fields")
|
||||
fieldNamesSet := runtime.Changed("field-names")
|
||||
projectionParams := make([]string, 0, 3)
|
||||
if fieldIDsSet {
|
||||
projectionParams = append(projectionParams, "--field-id")
|
||||
}
|
||||
if fieldsSet {
|
||||
projectionParams = append(projectionParams, "--fields")
|
||||
}
|
||||
if fieldNamesSet {
|
||||
projectionParams = append(projectionParams, "--field-names")
|
||||
}
|
||||
if len(projectionParams) > 1 {
|
||||
invalidParams := make([]errs.InvalidParam, 0, len(projectionParams))
|
||||
for _, param := range projectionParams {
|
||||
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
|
||||
}
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s are mutually exclusive", strings.Join(projectionParams, " and ")).
|
||||
WithParam(projectionParams[0]).
|
||||
WithParams(invalidParams...).
|
||||
WithHint("Use only --field-id for projection.")
|
||||
}
|
||||
if fieldsSet {
|
||||
return recordProjectionAliasFields(runtime, "fields", max)
|
||||
}
|
||||
if fieldNamesSet {
|
||||
return recordProjectionAliasFields(runtime, "field-names", max)
|
||||
}
|
||||
fields, err := normalizeRecordSelectFields(fieldIDs, max)
|
||||
return fields, withValidationParam(err, "--field-id")
|
||||
}
|
||||
|
||||
func recordProjectionAliasFields(runtime *common.RuntimeContext, flagName string, max int) ([]string, error) {
|
||||
var fields []string
|
||||
if flagName == "field-names" {
|
||||
fields = runtime.StrSlice(flagName)
|
||||
} else {
|
||||
pc := newParseCtx(runtime)
|
||||
values := runtime.StrArray(flagName)
|
||||
fields = make([]string, 0, len(values))
|
||||
for _, raw := range values {
|
||||
parsed, err := parseStringListFlexible(pc, raw, flagName)
|
||||
if err != nil {
|
||||
return nil, withValidationParam(err, "--"+flagName)
|
||||
}
|
||||
fields = append(fields, parsed...)
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
err := baseFlagErrorf("--%s must include at least one field", flagName)
|
||||
return nil, withValidationParam(err, "--"+flagName)
|
||||
}
|
||||
normalized, err := normalizeRecordSelectFields(fields, max)
|
||||
return normalized, withValidationParam(err, "--"+flagName)
|
||||
}
|
||||
|
||||
func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
@@ -392,7 +524,10 @@ func executeRecordList(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
limit := getPaginationLimit(runtime)
|
||||
params := map[string]interface{}{"offset": offset, "limit": limit}
|
||||
fields := recordListFields(runtime)
|
||||
fields, err := recordProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(fields) > 0 {
|
||||
params["field_id"] = fields
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
@@ -174,7 +175,10 @@ func recordSearchFlagBody(runtime *common.RuntimeContext) (map[string]interface{
|
||||
if len(searchFields) > 0 {
|
||||
body["search_fields"] = searchFields
|
||||
}
|
||||
selectFields := recordListFields(runtime)
|
||||
selectFields, err := recordSearchProjectionFields(runtime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(selectFields) > 0 {
|
||||
body["select_fields"] = selectFields
|
||||
}
|
||||
@@ -203,6 +207,19 @@ func recordSearchJSONBody(runtime *common.RuntimeContext) (map[string]interface{
|
||||
}
|
||||
|
||||
func normalizeRecordSearchJSONBody(body map[string]interface{}) error {
|
||||
if rawSelectFields, ok := body["select_fields"]; ok {
|
||||
if rawSelectFields == nil {
|
||||
delete(body, "select_fields")
|
||||
} else {
|
||||
selectFields, err := normalizeRecordSearchSelectFields(rawSelectFields)
|
||||
if err != nil {
|
||||
return withValidationParam(err, "--json")
|
||||
}
|
||||
if len(selectFields) > 0 {
|
||||
body["select_fields"] = selectFields
|
||||
}
|
||||
}
|
||||
}
|
||||
if rawSort, ok := body["sort"]; ok {
|
||||
if sortConfig, err := normalizeRecordSortValue(rawSort, "--json.sort"); err == nil {
|
||||
body["sort"] = sortConfig
|
||||
@@ -219,8 +236,20 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
}
|
||||
jsonRaw := strings.TrimSpace(runtime.Str("json"))
|
||||
if jsonRaw != "" {
|
||||
if recordSearchHasJSONExclusiveFlagInputs(runtime) {
|
||||
return baseFlagErrorf("--json is mutually exclusive with keyword/search/projection/pagination flags; put those fields inside --json, or omit --json")
|
||||
if exclusiveParams := recordSearchJSONExclusiveFlagParams(runtime); len(exclusiveParams) > 0 {
|
||||
allParams := append([]string{"--json"}, exclusiveParams...)
|
||||
invalidParams := make([]errs.InvalidParam, 0, len(allParams))
|
||||
for _, param := range allParams {
|
||||
invalidParams = append(invalidParams, errs.InvalidParam{Name: param, Reason: "mutually exclusive"})
|
||||
}
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--json is mutually exclusive with %s",
|
||||
strings.Join(exclusiveParams, " and "),
|
||||
).
|
||||
WithParam("--json").
|
||||
WithParams(invalidParams...).
|
||||
WithHint("Put keyword, search, projection, view, and pagination fields inside --json, or omit --json.")
|
||||
}
|
||||
_, err := recordSearchJSONBody(runtime)
|
||||
return err
|
||||
@@ -242,17 +271,31 @@ func validateRecordSearchFlags(runtime *common.RuntimeContext) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := recordSearchProjectionFields(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateRecordQueryOptions(runtime)
|
||||
}
|
||||
|
||||
func recordSearchHasJSONExclusiveFlagInputs(runtime *common.RuntimeContext) bool {
|
||||
return strings.TrimSpace(runtime.Str("keyword")) != "" ||
|
||||
len(runtime.StrArray("search-field")) > 0 ||
|
||||
len(recordListFields(runtime)) > 0 ||
|
||||
runtime.Str("view-id") != "" ||
|
||||
runtime.Changed("offset") ||
|
||||
runtime.Changed("limit") ||
|
||||
runtime.Changed("page-size")
|
||||
func recordSearchJSONExclusiveFlagParams(runtime *common.RuntimeContext) []string {
|
||||
names := []string{
|
||||
"keyword",
|
||||
"search-field",
|
||||
"field-id",
|
||||
"fields",
|
||||
"field-names",
|
||||
"view-id",
|
||||
"offset",
|
||||
"limit",
|
||||
"page-size",
|
||||
}
|
||||
params := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
if runtime.Changed(name) {
|
||||
params = append(params, "--"+name)
|
||||
}
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func formatRecordQueryPriorityTip() string {
|
||||
|
||||
@@ -23,7 +23,9 @@ var BaseRecordSearch = common.Shortcut{
|
||||
{Name: "json", Desc: `record search JSON object for the full request body, e.g. {"keyword":"Alice","search_fields":["Name"],"select_fields":["Name","Status"],"filter":{"logic":"and","conditions":[]},"sort":[{"field":"Updated","desc":true}],"limit":50}; escape hatch for advanced cases`},
|
||||
{Name: "keyword", Desc: "keyword for record search; required unless --json is used"},
|
||||
{Name: "search-field", Type: "string_array", Desc: "field ID or name to search; repeat for multiple fields; required unless --json is used"},
|
||||
recordListFieldRefFlag(),
|
||||
recordProjectionFieldFlag("field ID or name to include; repeat to project only needed fields"),
|
||||
recordProjectionAliasFlag("fields"),
|
||||
recordProjectionAliasFlag("field-names"),
|
||||
recordListViewRefFlag(),
|
||||
recordFilterFlag(),
|
||||
recordSortFlag(),
|
||||
|
||||
@@ -26,6 +26,7 @@ var BaseRecordUpsert = common.Shortcut{
|
||||
"Happy path JSON is a top-level field map: each key is a real field name or field ID, each value is that field's CellValue.",
|
||||
"Without --record-id this creates a record; with --record-id this updates that record. It does not auto-upsert by business key.",
|
||||
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
|
||||
"Sub-record/child-record path: when a one-way/two-way link field represents hierarchy, create a normal record and set that link field to a parent record reference array, e.g. {\"Parent Link\":[{\"id\":\"rec_xxx\"}]}; do not look for parent_record_id or a separate child-record API.",
|
||||
"Use the record-upsert guide for command limits and edge cases.",
|
||||
}, recordCellValueHappyPathTips...),
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
|
||||
@@ -67,6 +67,25 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
|
||||
return attendees, nil
|
||||
}
|
||||
|
||||
// selfAttendeeId resolves the open_id of the identity running the command so it
|
||||
// can be auto-added to the attendee list, mirroring how a human user is joined
|
||||
// to their own events. For a user it comes from config; for a bot it is fetched
|
||||
// from /bot/v3/info. If the bot lookup fails, we warn and return "" so the event
|
||||
// is still created with the explicitly requested attendees.
|
||||
func selfAttendeeId(runtime *common.RuntimeContext) string {
|
||||
if !runtime.IsBot() {
|
||||
return runtime.UserOpenId()
|
||||
}
|
||||
info, err := runtime.BotInfo()
|
||||
if err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut,
|
||||
"[calendar +create] warning: could not resolve bot identity to add it as an attendee (%v); proceeding without the bot\n",
|
||||
err)
|
||||
return ""
|
||||
}
|
||||
return info.OpenID
|
||||
}
|
||||
|
||||
func attendeesIncludeRoom(attendees []map[string]string) bool {
|
||||
for _, attendee := range attendees {
|
||||
if attendee["type"] == "resource" || attendee["room_id"] != "" {
|
||||
@@ -176,7 +195,9 @@ var CalendarCreate = common.Shortcut{
|
||||
eventData := buildEventData(runtime, startTs, endTs)
|
||||
attendeesStr := runtime.Str("attendee-ids")
|
||||
if attendeesStr != "" {
|
||||
// Note: dry-run doesn't network resolve the current user's open_id.
|
||||
// Note: dry-run doesn't network resolve the running identity's own
|
||||
// open_id (user from config, bot from /bot/v3/info), so the auto-joined
|
||||
// self attendee is not shown here.
|
||||
attendees, err := parseAttendees(attendeesStr, "")
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
@@ -228,11 +249,8 @@ var CalendarCreate = common.Shortcut{
|
||||
|
||||
// Add attendees if specified
|
||||
if attendeesStr := runtime.Str("attendee-ids"); attendeesStr != "" {
|
||||
currentUserId := ""
|
||||
if !runtime.IsBot() {
|
||||
currentUserId = runtime.UserOpenId()
|
||||
}
|
||||
attendees, err := parseAttendees(attendeesStr, currentUserId)
|
||||
selfId := selfAttendeeId(runtime)
|
||||
attendees, err := parseAttendees(attendeesStr, selfId)
|
||||
if err != nil {
|
||||
return withParam(err, "--attendee-ids")
|
||||
}
|
||||
|
||||
@@ -251,6 +251,136 @@ func TestCreate_WithAttendees_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_WithAttendees_AsBot_AddsBotSelf(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"bot": map[string]interface{}{
|
||||
"open_id": "ou_botself",
|
||||
"app_name": "Test Bot",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_bot",
|
||||
"summary": "Bot Sync",
|
||||
"start_time": map[string]interface{}{
|
||||
"timestamp": "1742515200",
|
||||
},
|
||||
"end_time": map[string]interface{}{
|
||||
"timestamp": "1742518800",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
attendeesStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/events/evt_bot/attendees",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(attendeesStub)
|
||||
|
||||
err := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Bot Sync",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--attendee-ids", "ou_user1",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if attendeesStub.CapturedBody == nil {
|
||||
t.Fatal("attendees API was not called")
|
||||
}
|
||||
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_botself")) {
|
||||
t.Fatalf("expected bot open_id ou_botself in attendees request, got: %s", attendeesStub.CapturedBody)
|
||||
}
|
||||
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_user1")) {
|
||||
t.Fatalf("expected requested attendee ou_user1 in attendees request, got: %s", attendeesStub.CapturedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_WithAttendees_AsBot_BotInfoFails_ProceedsWithoutBot(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/bot/v3/info",
|
||||
Body: map[string]interface{}{
|
||||
"code": 99991663, "msg": "app ticket invalid",
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"event": map[string]interface{}{
|
||||
"event_id": "evt_nobot",
|
||||
"summary": "Bot Sync",
|
||||
"start_time": map[string]interface{}{
|
||||
"timestamp": "1742515200",
|
||||
},
|
||||
"end_time": map[string]interface{}{
|
||||
"timestamp": "1742518800",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
attendeesStub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/events/evt_nobot/attendees",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{},
|
||||
},
|
||||
}
|
||||
reg.Register(attendeesStub)
|
||||
|
||||
err := mountAndRun(t, CalendarCreate, []string{
|
||||
"+create",
|
||||
"--summary", "Bot Sync",
|
||||
"--start", "2025-03-21T00:00:00+08:00",
|
||||
"--end", "2025-03-21T01:00:00+08:00",
|
||||
"--calendar-id", "cal_test123",
|
||||
"--attendee-ids", "ou_user1",
|
||||
"--as", "bot",
|
||||
}, f, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if attendeesStub.CapturedBody == nil {
|
||||
t.Fatal("attendees API was not called")
|
||||
}
|
||||
if !bytes.Contains(attendeesStub.CapturedBody, []byte("ou_user1")) {
|
||||
t.Fatalf("expected requested attendee ou_user1 in attendees request, got: %s", attendeesStub.CapturedBody)
|
||||
}
|
||||
if bytes.Contains(attendeesStub.CapturedBody, []byte("ou_botself")) {
|
||||
t.Fatalf("bot open_id should be absent when /bot/v3/info fails, got: %s", attendeesStub.CapturedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreate_WithAttendees_APIError_RollsBack(t *testing.T) {
|
||||
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||
|
||||
|
||||
@@ -665,16 +665,59 @@ func (ctx *RuntimeContext) ValidatePath(path string) error {
|
||||
|
||||
// ── Output helpers ──
|
||||
|
||||
func (ctx *RuntimeContext) newEmitter() *output.Emitter {
|
||||
streams := ctx.IO()
|
||||
return output.NewEmitter(output.EmitterConfig{
|
||||
Out: streams.Out,
|
||||
ErrOut: streams.ErrOut,
|
||||
CommandPath: ctx.Cmd.CommandPath(),
|
||||
Identity: string(ctx.As()),
|
||||
ColorEnabled: streams.OutIsTerminal,
|
||||
NoticeProvider: output.GetNotice,
|
||||
})
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) handleEmitterError(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
var cs *errs.ContentSafetyError
|
||||
if ctx.JqExpr != "" && !errors.As(err, &cs) {
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
|
||||
}
|
||||
ctx.outputErrOnce.Do(func() { ctx.outputErr = err })
|
||||
}
|
||||
|
||||
func wrapLegacyPrettyRenderer(prettyFn func(w io.Writer)) output.PrettyRenderer {
|
||||
if prettyFn == nil {
|
||||
return nil
|
||||
}
|
||||
return func(w io.Writer, _ bool) error {
|
||||
prettyFn(w)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Out prints a success JSON envelope to stdout.
|
||||
func (ctx *RuntimeContext) Out(data interface{}, meta *output.Meta) {
|
||||
ctx.emit(data, meta, false, true)
|
||||
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
|
||||
Format: "",
|
||||
Raw: false,
|
||||
JQ: ctx.JqExpr,
|
||||
Meta: meta,
|
||||
}))
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
|
||||
ctx.emit(data, meta, true, true)
|
||||
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
|
||||
Format: "",
|
||||
Raw: true,
|
||||
JQ: ctx.JqExpr,
|
||||
Meta: meta,
|
||||
}))
|
||||
}
|
||||
|
||||
// OutPartialFailure writes an ok:false multi-status result envelope to stdout
|
||||
@@ -688,112 +731,42 @@ func (ctx *RuntimeContext) OutRaw(data interface{}, meta *output.Meta) {
|
||||
// ok:true, 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 {
|
||||
ctx.emit(data, meta, false, false)
|
||||
ctx.handleEmitterError(ctx.newEmitter().PartialFailure(data, output.EmitOptions{
|
||||
Format: "",
|
||||
Raw: false,
|
||||
JQ: ctx.JqExpr,
|
||||
Meta: meta,
|
||||
}))
|
||||
if ctx.outputErr != nil {
|
||||
return ctx.outputErr
|
||||
}
|
||||
return output.PartialFailure(output.ExitAPI)
|
||||
}
|
||||
|
||||
// emit is the shared stdout envelope emitter; ok sets the envelope's ok field
|
||||
// (true for success, false for a partial-failure result). raw=true disables JSON
|
||||
// HTML escaping so XML/HTML payloads (e.g. DocxXML bodies) are preserved
|
||||
// verbatim; otherwise behavior
|
||||
// is identical — content-safety scanning and race-safe first-error capture via
|
||||
// outputErrOnce apply in both modes.
|
||||
func (ctx *RuntimeContext) emit(data interface{}, meta *output.Meta, raw, ok bool) {
|
||||
scanResult := output.ScanForSafety(ctx.Cmd.CommandPath(), data, ctx.IO().ErrOut)
|
||||
if scanResult.Blocked {
|
||||
ctx.outputErrOnce.Do(func() { ctx.outputErr = scanResult.BlockErr })
|
||||
return
|
||||
}
|
||||
|
||||
env := output.Envelope{OK: ok, Identity: string(ctx.As()), Data: data, Meta: meta, Notice: output.GetNotice()}
|
||||
if scanResult.Alert != nil {
|
||||
env.ContentSafetyAlert = scanResult.Alert
|
||||
}
|
||||
|
||||
if ctx.JqExpr != "" {
|
||||
filter := output.JqFilter
|
||||
if raw {
|
||||
filter = output.JqFilterRaw
|
||||
}
|
||||
if err := filter(ctx.IO().Out, env, ctx.JqExpr); err != nil {
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "error: %v\n", err)
|
||||
ctx.outputErrOnce.Do(func() { ctx.outputErr = err })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if raw {
|
||||
enc := json.NewEncoder(ctx.IO().Out)
|
||||
enc.SetEscapeHTML(false)
|
||||
enc.SetIndent("", " ")
|
||||
_ = enc.Encode(env)
|
||||
return
|
||||
}
|
||||
b, _ := json.MarshalIndent(env, "", " ")
|
||||
fmt.Fprintln(ctx.IO().Out, string(b))
|
||||
}
|
||||
|
||||
// OutFormat prints output based on --format flag.
|
||||
// "json" (default) outputs JSON envelope; "pretty" calls prettyFn; others delegate to FormatValue.
|
||||
// When JqExpr is set, routes through Out() regardless of format.
|
||||
// For json/"" and jq paths, Out() handles content safety scanning.
|
||||
// For pretty/table/csv/ndjson, scanning is done here and the alert is written to stderr.
|
||||
// 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.outFormat(data, meta, prettyFn, false)
|
||||
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
|
||||
Format: ctx.Format,
|
||||
Raw: false,
|
||||
JQ: ctx.JqExpr,
|
||||
Meta: meta,
|
||||
Pretty: wrapLegacyPrettyRenderer(prettyFn),
|
||||
}))
|
||||
}
|
||||
|
||||
// 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.outFormat(data, meta, prettyFn, true)
|
||||
}
|
||||
|
||||
func (ctx *RuntimeContext) outFormat(data interface{}, meta *output.Meta, prettyFn func(w io.Writer), raw bool) {
|
||||
outFn := ctx.Out
|
||||
if raw {
|
||||
outFn = ctx.OutRaw
|
||||
}
|
||||
if ctx.JqExpr != "" {
|
||||
outFn(data, meta)
|
||||
return
|
||||
}
|
||||
switch ctx.Format {
|
||||
case "pretty":
|
||||
scanResult := output.ScanForSafety(ctx.Cmd.CommandPath(), data, ctx.IO().ErrOut)
|
||||
if scanResult.Blocked {
|
||||
ctx.outputErrOnce.Do(func() { ctx.outputErr = scanResult.BlockErr })
|
||||
return
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(ctx.IO().ErrOut, scanResult.Alert)
|
||||
}
|
||||
if prettyFn != nil {
|
||||
prettyFn(ctx.IO().Out)
|
||||
} else {
|
||||
outFn(data, meta)
|
||||
}
|
||||
case "json", "":
|
||||
outFn(data, meta)
|
||||
default:
|
||||
// table, csv, ndjson — pass data directly; FormatValue handles both
|
||||
// plain arrays and maps with array fields (e.g. {"members":[…]})
|
||||
scanResult := output.ScanForSafety(ctx.Cmd.CommandPath(), data, ctx.IO().ErrOut)
|
||||
if scanResult.Blocked {
|
||||
ctx.outputErrOnce.Do(func() { ctx.outputErr = scanResult.BlockErr })
|
||||
return
|
||||
}
|
||||
if scanResult.Alert != nil {
|
||||
output.WriteAlertWarning(ctx.IO().ErrOut, scanResult.Alert)
|
||||
}
|
||||
format, formatOK := output.ParseFormat(ctx.Format)
|
||||
if !formatOK {
|
||||
fmt.Fprintf(ctx.IO().ErrOut, "warning: unknown format %q, falling back to json\n", ctx.Format)
|
||||
}
|
||||
output.FormatValue(ctx.IO().Out, data, format)
|
||||
}
|
||||
ctx.handleEmitterError(ctx.newEmitter().Success(data, output.EmitOptions{
|
||||
Format: ctx.Format,
|
||||
Raw: true,
|
||||
JQ: ctx.JqExpr,
|
||||
Meta: meta,
|
||||
Pretty: wrapLegacyPrettyRenderer(prettyFn),
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Scope pre-check ──
|
||||
|
||||
@@ -7,10 +7,12 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
extcs "github.com/larksuite/cli/extension/contentsafety"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -71,7 +73,7 @@ func TestOut_ContentSafetyBlock(t *testing.T) {
|
||||
extcs.Register(&csTestProvider{alert: alert})
|
||||
defer extcs.Register(nil)
|
||||
|
||||
rctx, stdout, _ := newCSTestContext(t)
|
||||
rctx, stdout, stderr := newCSTestContext(t)
|
||||
rctx.Out(map[string]any{"msg": "hello"}, nil)
|
||||
|
||||
if stdout.Len() > 0 {
|
||||
@@ -80,6 +82,16 @@ func TestOut_ContentSafetyBlock(t *testing.T) {
|
||||
if rctx.outputErr == nil {
|
||||
t.Error("block mode should set outputErr")
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("block mode stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
var safetyErr *errs.ContentSafetyError
|
||||
if !errors.As(rctx.outputErr, &safetyErr) {
|
||||
t.Fatalf("block mode output error = %T, want *errs.ContentSafetyError", rctx.outputErr)
|
||||
}
|
||||
if got := output.ExitCodeOf(rctx.outputErr); got != output.ExitContentSafety {
|
||||
t.Fatalf("block mode exit code = %d, want %d", got, output.ExitContentSafety)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOut_ContentSafetyOff(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
lark "github.com/larksuite/oapi-sdk-go/v3"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
@@ -102,6 +104,72 @@ func TestRuntimeContext_Out_WithJq_InvalidExpr_WritesStderr(t *testing.T) {
|
||||
if !strings.Contains(stderr.String(), "error") {
|
||||
t.Errorf("expected error on stderr for runtime jq error, got: %s", stderr.String())
|
||||
}
|
||||
problem, ok := errs.ProblemOf(rctx.outputErr)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("output error problem = %#v, %v; want validation/invalid_argument", problem, ok)
|
||||
}
|
||||
if got := output.ExitCodeOf(rctx.outputErr); got != output.ExitValidation {
|
||||
t.Fatalf("output error exit code = %d, want %d", got, output.ExitValidation)
|
||||
}
|
||||
}
|
||||
|
||||
type failingRuntimeOutputWriter struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (w failingRuntimeOutputWriter) Write([]byte) (int, error) {
|
||||
return 0, w.err
|
||||
}
|
||||
|
||||
func TestRuntimeContext_OutRaw_PropagatesWriteError(t *testing.T) {
|
||||
rctx, _, stderr := newJqTestContext("", "")
|
||||
sentinel := errors.New("write failed")
|
||||
rctx.Factory.IOStreams.Out = failingRuntimeOutputWriter{err: sentinel}
|
||||
|
||||
rctx.OutRaw(map[string]interface{}{"id": "1"}, nil)
|
||||
|
||||
if !errors.Is(rctx.outputErr, sentinel) {
|
||||
t.Fatalf("OutRaw() output error = %v, want preserved writer cause", rctx.outputErr)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(rctx.outputErr)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("OutRaw() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if got := output.ExitCodeOf(rctx.outputErr); got != output.ExitInternal {
|
||||
t.Fatalf("OutRaw() exit code = %d, want %d", got, output.ExitInternal)
|
||||
}
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("OutRaw() stderr = %q, want empty", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunShortcut_OutRawWriteErrorPropagates(t *testing.T) {
|
||||
sentinel := errors.New("write failed")
|
||||
f := newTestFactory()
|
||||
f.IOStreams.Out = failingRuntimeOutputWriter{err: sentinel}
|
||||
s := &Shortcut{
|
||||
Service: "test",
|
||||
Command: "test-shortcut",
|
||||
AuthTypes: []string{"bot"},
|
||||
Execute: func(_ context.Context, rctx *RuntimeContext) error {
|
||||
rctx.OutRaw(map[string]interface{}{"id": "1"}, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd := newTestShortcutCmd(s, f)
|
||||
cmd.Flags().Set("as", "bot")
|
||||
|
||||
err := runShortcut(cmd, f, s, true)
|
||||
if !errors.Is(err, sentinel) {
|
||||
t.Fatalf("runShortcut() error = %v, want preserved writer cause", err)
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryInternal {
|
||||
t.Fatalf("runShortcut() problem = %#v, %v; want internal typed error", problem, ok)
|
||||
}
|
||||
if got := output.ExitCodeOf(err); got != output.ExitInternal {
|
||||
t.Fatalf("runShortcut() exit code = %d, want %d", got, output.ExitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
type testResolvedFileIO struct{}
|
||||
|
||||
@@ -356,11 +356,26 @@ func TestValidateUpdateV2Contract(t *testing.T) {
|
||||
str: map[string]string{"doc": testDocxToken, "command": "str_replace"},
|
||||
wantParam: "--pattern",
|
||||
},
|
||||
{
|
||||
name: "XML str_replace rejects multiline pattern",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "str_replace", "doc-format": "xml", "pattern": "line one\nline two", "content": "replacement"},
|
||||
wantParam: "--pattern",
|
||||
},
|
||||
{
|
||||
name: "block_delete without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_delete rejects empty ID",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA,,blkB"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_delete rejects duplicate ID",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_delete", "block-id": "blkA, blkA"},
|
||||
wantParam: "--block-id",
|
||||
},
|
||||
{
|
||||
name: "block_insert_after without block id",
|
||||
str: map[string]string{"doc": testDocxToken, "command": "block_insert_after"},
|
||||
|
||||
@@ -17,6 +17,46 @@ import (
|
||||
|
||||
// ── V2 (OpenAPI) tests ──
|
||||
|
||||
func TestStripTopLevelXMLTitles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
content string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "single title",
|
||||
content: "<title>Content title</title><p>body</p>",
|
||||
want: "<p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "multiple titles",
|
||||
content: "<title>First</title>\n<p>body</p>\n<title>Second</title>",
|
||||
want: "<p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "nested title is preserved",
|
||||
content: "<callout><title>Nested</title></callout><p>body</p>",
|
||||
want: "<callout><title>Nested</title></callout><p>body</p>",
|
||||
},
|
||||
{
|
||||
name: "malformed XML is preserved",
|
||||
content: "<title>Content title</title><p>A & B</p>",
|
||||
want: "<title>Content title</title><p>A & B</p>",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := stripTopLevelXMLTitles(tt.content); got != tt.want {
|
||||
t.Fatalf("stripTopLevelXMLTitles() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocsCreateV2BotAutoGrantSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
@@ -16,7 +18,7 @@ import (
|
||||
// v2CreateFlags returns the flag definitions for the v2 (OpenAPI) create path.
|
||||
func v2CreateFlags() []common.Flag {
|
||||
return []common.Flag{
|
||||
{Name: "title", Desc: "document title; when provided, the CLI prepends it to --content as <title>...</title> so the title wins over later content titles"},
|
||||
{Name: "title", Desc: "document title; the CLI prepends it to --content as <title>...</title>. In XML mode, top-level <title> elements in --content are removed so this flag wins without duplicate-title warnings"},
|
||||
{Name: "content", Desc: "document body; XML by default or Markdown when --doc-format markdown. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "doc-format", Desc: "content format; xml is default and supports richer DocxXML blocks, markdown imports plain Markdown", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
@@ -108,6 +110,9 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
|
||||
if title == "" {
|
||||
return content
|
||||
}
|
||||
if runtime.Str("doc-format") == "xml" {
|
||||
content = stripTopLevelXMLTitles(content)
|
||||
}
|
||||
|
||||
titleTag := "<title>" + escapeDocTitleText(title) + "</title>"
|
||||
if content == "" {
|
||||
@@ -116,6 +121,62 @@ func buildCreateContentWithBody(runtime *common.RuntimeContext, content string)
|
||||
return titleTag + "\n" + content
|
||||
}
|
||||
|
||||
type docContentRange struct {
|
||||
start int64
|
||||
end int64
|
||||
}
|
||||
|
||||
// stripTopLevelXMLTitles preserves the established --title-wins contract while
|
||||
// avoiding duplicate-title warnings from XML content. If the fragment is not
|
||||
// well-formed XML, it is left untouched for the service to diagnose.
|
||||
func stripTopLevelXMLTitles(content string) string {
|
||||
const wrapperStart = "<root>"
|
||||
wrapped := wrapperStart + content + "</root>"
|
||||
decoder := xml.NewDecoder(strings.NewReader(wrapped))
|
||||
wrapperLen := int64(len(wrapperStart))
|
||||
depth := 0
|
||||
activeStart := int64(-1)
|
||||
ranges := make([]docContentRange, 0, 1)
|
||||
|
||||
for {
|
||||
tokenStart := decoder.InputOffset()
|
||||
token, err := decoder.Token()
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return content
|
||||
}
|
||||
|
||||
switch value := token.(type) {
|
||||
case xml.StartElement:
|
||||
if depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
|
||||
activeStart = tokenStart - wrapperLen
|
||||
}
|
||||
depth++
|
||||
case xml.EndElement:
|
||||
depth--
|
||||
if activeStart >= 0 && depth == 1 && value.Name.Space == "" && value.Name.Local == "title" {
|
||||
ranges = append(ranges, docContentRange{start: activeStart, end: decoder.InputOffset() - wrapperLen})
|
||||
activeStart = -1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ranges) == 0 {
|
||||
return content
|
||||
}
|
||||
|
||||
var result strings.Builder
|
||||
cursor := int64(0)
|
||||
for _, item := range ranges {
|
||||
result.WriteString(content[int(cursor):int(item.start)])
|
||||
cursor = item.end
|
||||
}
|
||||
result.WriteString(content[int(cursor):])
|
||||
return strings.TrimSpace(result.String())
|
||||
}
|
||||
|
||||
func escapeDocTitleText(title string) string {
|
||||
var buf bytes.Buffer
|
||||
_ = xml.EscapeText(&buf, []byte(title))
|
||||
|
||||
@@ -35,8 +35,8 @@ func v2UpdateFlags() []common.Flag {
|
||||
{Name: "doc-format", Desc: "content format for --content; xml is default for precise rich edits, markdown for user-provided Markdown or plain append/overwrite", Default: "xml", Enum: []string{"xml", "markdown"}},
|
||||
{Name: "content", Desc: "replacement or inserted content; XML by default or Markdown when --doc-format markdown; empty with str_replace deletes match. " + docsContentSkillHelp + "; use --help for the latest command flags", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "reference-map", Desc: docsUpdateReferenceMapFlagDesc, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode is inline text, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated for batch delete); -1 means document end where supported"},
|
||||
{Name: "pattern", Desc: "str_replace match pattern; XML mode accepts inline text only, Markdown mode can match multiline text"},
|
||||
{Name: "block-id", Desc: "target block ID(s) for block operations (comma-separated unique IDs for batch delete); -1 means document end where supported"},
|
||||
{Name: "src-block-ids", Desc: "comma-separated source block ids for block_copy_insert_after and block_move_after"},
|
||||
{Name: "revision-id", Desc: "base revision id; -1 means latest", Type: "int", Default: "-1"},
|
||||
}
|
||||
@@ -73,10 +73,16 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
if pattern == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command str_replace requires --pattern").WithParam("--pattern")
|
||||
}
|
||||
if runtime.Str("doc-format") == "xml" && strings.ContainsAny(pattern, "\r\n") {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "XML str_replace --pattern must be inline and cannot contain line breaks; use --doc-format markdown or a block operation for multiline changes").WithParam("--pattern")
|
||||
}
|
||||
case "block_delete":
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_delete requires --block-id").WithParam("--block-id")
|
||||
}
|
||||
if err := validateBlockDeleteIDs(blockID); err != nil {
|
||||
return err
|
||||
}
|
||||
case "block_insert_after":
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--command block_insert_after requires --block-id").WithParam("--block-id")
|
||||
@@ -124,6 +130,29 @@ func validateUpdateV2(_ context.Context, runtime *common.RuntimeContext) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBlockDeleteIDs(raw string) error {
|
||||
seen := make(map[string]struct{})
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
blockID := strings.TrimSpace(part)
|
||||
if blockID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains an empty ID; provide a comma-separated list of non-empty block IDs").WithParam("--block-id")
|
||||
}
|
||||
if _, ok := seen[blockID]; ok {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--block-id contains duplicate ID %q; each block may be deleted only once per request", blockID).WithParam("--block-id")
|
||||
}
|
||||
seen[blockID] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeBlockDeleteIDs(raw string) string {
|
||||
parts := strings.Split(raw, ",")
|
||||
for i := range parts {
|
||||
parts[i] = strings.TrimSpace(parts[i])
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
func dryRunUpdateV2(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
// Validate has already accepted --doc; parseDocumentRef cannot fail here.
|
||||
ref, _ := parseDocumentRef(runtime.Str("doc"))
|
||||
@@ -199,6 +228,9 @@ func buildUpdateBodyBase(runtime *common.RuntimeContext) map[string]interface{}
|
||||
body["pattern"] = v
|
||||
}
|
||||
if blockID != "" {
|
||||
if cmd == "block_delete" {
|
||||
blockID = normalizeBlockDeleteIDs(blockID)
|
||||
}
|
||||
body["block_id"] = blockID
|
||||
}
|
||||
if v := runtime.Str("src-block-ids"); v != "" {
|
||||
|
||||
@@ -27,8 +27,8 @@ var ImFlagList = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "page-size", Type: "int", Default: "50", Desc: "page size (1-50)"},
|
||||
{Name: "page-token", Desc: "pagination token for next page"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages when auto-pagination is enabled (default 20, max 1000)"},
|
||||
{Name: "page-all", Type: "bool", Desc: "automatically paginate, capped by --page-limit"},
|
||||
{Name: "page-limit", Type: "int", Default: "20", Desc: "max pages with --page-all (default 20; configurable range 1-1000)"},
|
||||
{Name: "enrich-feed-thread", Type: "bool", Default: "true", Desc: "fetch message content for feed-type thread entries (default true; may call messages/mget and require im:message.group_msg:get_as_user/im:message.p2p_msg:get_as_user; use --enrich-feed-thread=false to avoid extra scopes)"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -278,6 +278,10 @@ func executeListAllPages(rt *common.RuntimeContext) error {
|
||||
fmt.Fprintf(rt.IO().ErrOut, "warning: page_token did not change, stopping pagination to avoid infinite loop\n")
|
||||
break
|
||||
}
|
||||
if page+1 >= maxPages {
|
||||
fmt.Fprintf(rt.IO().ErrOut, "[pagination] reached page limit (%d) while has_more=true; result is incomplete. Increase --page-limit up to 1000 or resume with the page_token returned in stdout.\n", maxPages)
|
||||
break
|
||||
}
|
||||
prevPageToken = lastPageToken
|
||||
}
|
||||
|
||||
|
||||
@@ -1536,6 +1536,9 @@ func TestExecuteListAllPages(t *testing.T) {
|
||||
if callCount != 2 {
|
||||
t.Fatalf("expected 2 API calls, got %d", callCount)
|
||||
}
|
||||
if stderr := rt.IO().ErrOut.(*bytes.Buffer).String(); strings.Contains(stderr, "reached page limit") {
|
||||
t.Fatalf("natural pagination completion must not warn about a page limit, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteListAllPages_EnrichFeedThread(t *testing.T) {
|
||||
@@ -1625,6 +1628,73 @@ func TestExecuteListAllPages_PageLimit(t *testing.T) {
|
||||
if callCount != 3 {
|
||||
t.Fatalf("expected 3 API calls (page limit), got %d", callCount)
|
||||
}
|
||||
stderr := rt.IO().ErrOut.(*bytes.Buffer).String()
|
||||
for _, want := range []string{"reached page limit (3)", "has_more=true", "result is incomplete", "up to 1000", "page_token returned in stdout"} {
|
||||
if !strings.Contains(stderr, want) {
|
||||
t.Fatalf("stderr = %q, want %q", stderr, want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(stderr, "token_3") {
|
||||
t.Fatalf("stderr must not expose the continuation token, got %q", stderr)
|
||||
}
|
||||
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal(rt.IO().Out.(*bytes.Buffer).Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode stdout: %v", err)
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]any)
|
||||
if hasMore, _ := data["has_more"].(bool); !hasMore {
|
||||
t.Fatalf("has_more = %#v, want true for an incomplete result", data["has_more"])
|
||||
}
|
||||
if pageToken, _ := data["page_token"].(string); pageToken != "token_3" {
|
||||
t.Fatalf("page_token = %q, want token_3", pageToken)
|
||||
}
|
||||
if _, exists := data["truncated"]; exists {
|
||||
t.Fatalf("output schema must remain unchanged; unexpected truncated field in %#v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteListAllPages_RepeatedTokenDoesNotReportPageLimit(t *testing.T) {
|
||||
callCount := 0
|
||||
rt := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if strings.Contains(req.URL.Path, "/open-apis/im/v1/flags") {
|
||||
callCount++
|
||||
return shortcutJSONResponse(200, map[string]any{
|
||||
"code": 0,
|
||||
"data": map[string]any{
|
||||
"flag_items": []any{},
|
||||
"delete_flag_items": []any{},
|
||||
"messages": []any{},
|
||||
"has_more": true,
|
||||
"page_token": "same_token",
|
||||
},
|
||||
}), nil
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected request: %s", req.URL.Path)
|
||||
}))
|
||||
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
cmd.Flags().Int("page-size", 50, "")
|
||||
cmd.Flags().Int("page-limit", 10, "")
|
||||
cmd.Flags().Bool("enrich-feed-thread", false, "")
|
||||
if err := cmd.ParseFlags(nil); err != nil {
|
||||
t.Fatalf("ParseFlags() error = %v", err)
|
||||
}
|
||||
setRuntimeField(t, rt, "Cmd", cmd)
|
||||
|
||||
if err := executeListAllPages(rt); err != nil {
|
||||
t.Fatalf("executeListAllPages() error = %v", err)
|
||||
}
|
||||
if callCount != 2 {
|
||||
t.Fatalf("API calls = %d, want 2 before repeated-token stop", callCount)
|
||||
}
|
||||
stderr := rt.IO().ErrOut.(*bytes.Buffer).String()
|
||||
if !strings.Contains(stderr, "page_token did not change") {
|
||||
t.Fatalf("stderr = %q, want non-advancing token warning", stderr)
|
||||
}
|
||||
if strings.Contains(stderr, "reached page limit") {
|
||||
t.Fatalf("stderr = %q, repeated token must not be reported as a page-limit stop", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteListAllPages_APIError(t *testing.T) {
|
||||
|
||||
@@ -24,9 +24,12 @@ type batchCreateKR struct {
|
||||
|
||||
// batchCreateObjective represents an objective in the batch create input.
|
||||
type batchCreateObjective struct {
|
||||
Text string `json:"text"`
|
||||
Mention []string `json:"mention,omitempty"`
|
||||
KRs []batchCreateKR `json:"krs,omitempty"`
|
||||
Text string `json:"text"`
|
||||
Mention []string `json:"mention,omitempty"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
NotesMention []string `json:"notes_mention,omitempty"`
|
||||
CategoryID string `json:"category_id,omitempty"`
|
||||
KRs []batchCreateKR `json:"krs,omitempty"`
|
||||
}
|
||||
|
||||
// createdObjective tracks a created objective and its KR IDs for output.
|
||||
@@ -49,6 +52,25 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
|
||||
if strings.TrimSpace(obj.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].text is required and cannot be empty", i).WithParam("--input")
|
||||
}
|
||||
if obj.Notes != "" && strings.TrimSpace(obj.Notes) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes cannot be blank when provided", i).WithParam("--input")
|
||||
}
|
||||
if obj.Notes == "" && len(obj.NotesMention) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes is required when notes_mention is provided", i).WithParam("--input")
|
||||
}
|
||||
for j, mention := range obj.NotesMention {
|
||||
if strings.TrimSpace(mention) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].notes_mention[%d] cannot be empty", i, j).WithParam("--input")
|
||||
}
|
||||
}
|
||||
if obj.CategoryID != "" {
|
||||
if strings.TrimSpace(obj.CategoryID) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].category_id cannot be blank when provided", i).WithParam("--input")
|
||||
}
|
||||
if id, err := strconv.ParseInt(obj.CategoryID, 10, 64); err != nil || id <= 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].category_id must be a positive int64", i).WithParam("--input")
|
||||
}
|
||||
}
|
||||
for j, kr := range obj.KRs {
|
||||
if strings.TrimSpace(kr.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "objective[%d].krs[%d].text is required and cannot be empty", i, j).WithParam("--input")
|
||||
@@ -59,11 +81,24 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
|
||||
}
|
||||
|
||||
// createObjective calls the API to create an objective.
|
||||
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
|
||||
func effectiveBatchObjectiveCategoryID(defaultCategoryID string, obj batchCreateObjective) string {
|
||||
if obj.CategoryID != "" {
|
||||
return obj.CategoryID
|
||||
}
|
||||
return defaultCategoryID
|
||||
}
|
||||
|
||||
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType, defaultCategoryID string, obj batchCreateObjective) (string, error) {
|
||||
content := BuildContentBlock(obj.Text, obj.Mention)
|
||||
body := map[string]interface{}{
|
||||
"content": content,
|
||||
}
|
||||
if obj.Notes != "" {
|
||||
body["notes"] = BuildContentBlock(obj.Notes, obj.NotesMention)
|
||||
}
|
||||
if categoryID := effectiveBatchObjectiveCategoryID(defaultCategoryID, obj); categoryID != "" {
|
||||
body["category_id"] = categoryID
|
||||
}
|
||||
queryParams := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"user_id_type": userIDType,
|
||||
@@ -156,6 +191,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "cycle-id", Desc: "OKR cycle ID (int64)", Required: true},
|
||||
{Name: "input", Desc: "JSON array of objectives: [{\"text\":\"...\",\"mention\":[\"...\"],\"krs\":[{\"text\":\"...\",\"mention\":[\"...\"]}]}]", Input: []string{common.File, common.Stdin}, Required: true},
|
||||
{Name: "category-id", Desc: "default objective category ID for objectives that do not set category_id"},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
@@ -171,6 +207,15 @@ var OKRBatchCreate = common.Shortcut{
|
||||
if _, err := parseBatchCreateInput(input); err != nil {
|
||||
return err
|
||||
}
|
||||
categoryID := runtime.Str("category-id")
|
||||
if categoryID != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--category-id", categoryID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(categoryID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id must be a positive int64").WithParam("--category-id")
|
||||
}
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
@@ -182,6 +227,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
defaultCategoryID := runtime.Str("category-id")
|
||||
objectives, _ := parseBatchCreateInput(runtime.Str("input"))
|
||||
|
||||
apis := common.NewDryRunAPI()
|
||||
@@ -192,6 +238,12 @@ var OKRBatchCreate = common.Shortcut{
|
||||
objBody := map[string]interface{}{
|
||||
"content": objContent,
|
||||
}
|
||||
if obj.Notes != "" {
|
||||
objBody["notes"] = BuildContentBlock(obj.Notes, obj.NotesMention)
|
||||
}
|
||||
if categoryID := effectiveBatchObjectiveCategoryID(defaultCategoryID, obj); categoryID != "" {
|
||||
objBody["category_id"] = categoryID
|
||||
}
|
||||
objParams := map[string]interface{}{
|
||||
"cycle_id": cycleID,
|
||||
"user_id_type": userIDType,
|
||||
@@ -227,6 +279,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
userIDType := runtime.Str("user-id-type")
|
||||
defaultCategoryID := runtime.Str("category-id")
|
||||
objectives, err := parseBatchCreateInput(runtime.Str("input"))
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -241,7 +294,7 @@ var OKRBatchCreate = common.Shortcut{
|
||||
}
|
||||
|
||||
// Create objective
|
||||
objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, obj)
|
||||
objectiveID, err := createObjective(ctx, runtime, cycleID, userIDType, defaultCategoryID, obj)
|
||||
if err != nil {
|
||||
if len(created) == 0 {
|
||||
return err
|
||||
|
||||
@@ -6,6 +6,8 @@ package okr
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -14,6 +16,7 @@ import (
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func batchCreateTestConfig(t *testing.T) *core.CliConfig {
|
||||
@@ -43,6 +46,15 @@ const validBatchCreateInput = `[
|
||||
{"text":"Objective 2","krs":[{"text":"KR 2.1"},{"text":"KR 2.2"}]}
|
||||
]`
|
||||
|
||||
const validBatchCreateInputWithNotes = `[
|
||||
{"text":"Objective 1","notes":"Objective notes","notes_mention":["ou_note"],"krs":[{"text":"KR 1.1"}]}
|
||||
]`
|
||||
|
||||
const validBatchCreateInputWithCategory = `[
|
||||
{"text":"Objective 1","category_id":"222","krs":[{"text":"KR 1.1"}]},
|
||||
{"text":"Objective 2","krs":[]}
|
||||
]`
|
||||
|
||||
// --- Validate tests ---
|
||||
|
||||
func TestBatchCreateValidate_MissingCycleID(t *testing.T) {
|
||||
@@ -197,6 +209,46 @@ func TestBatchCreateValidate_EmptyKRText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_EmptyObjectiveNotesMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1","notes":"Notes","notes_mention":[" "]}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty objective notes mention")
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok || validationErr.Param != "--input" {
|
||||
t.Fatalf("expected param --input, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "objective[0].notes_mention[0]") {
|
||||
t.Fatalf("expected error to mention objective[0].notes_mention[0], got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_NotesMentionRequiresNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", `[{"text":"Obj 1","notes_mention":["ou_note"]}]`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for notes_mention without notes")
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok || validationErr.Param != "--input" {
|
||||
t.Fatalf("expected param --input, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "objective[0].notes is required when notes_mention is provided") {
|
||||
t.Fatalf("expected error to mention missing notes, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateValidate_InvalidUserIDType(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
@@ -323,6 +375,49 @@ func TestBatchCreateDryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateDryRun_WithObjectiveNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", validBatchCreateInputWithNotes,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "Objective notes") {
|
||||
t.Fatalf("dry-run output should contain objective notes, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "ou_note") {
|
||||
t.Fatalf("dry-run output should contain objective notes mention, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateDryRun_WithCategoryID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--category-id", "111",
|
||||
"--input", validBatchCreateInputWithCategory,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.body.category_id").String(); got != "222" {
|
||||
t.Fatalf("first objective category_id = %q, want per-objective override 222; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.2.body.category_id").String(); got != "111" {
|
||||
t.Fatalf("second objective category_id = %q, want default 111; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestBatchCreateExecute_Success(t *testing.T) {
|
||||
@@ -380,6 +475,94 @@ func TestBatchCreateExecute_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_ObjectiveWithNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
var objectiveBody []byte
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
OnMatch: func(req *http.Request) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read objective request body: %v", err)
|
||||
}
|
||||
objectiveBody = body
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/100/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"key_result_id": "200",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--input", validBatchCreateInputWithNotes,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.0.text_run.text").Exists() {
|
||||
t.Fatalf("objective request body missing notes: %s", string(objectiveBody))
|
||||
}
|
||||
if got := gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.0.text_run.text").String(); got != "Objective notes" {
|
||||
t.Fatalf("notes text = %q, want Objective notes; body: %s", got, string(objectiveBody))
|
||||
}
|
||||
if got := gjson.GetBytes(objectiveBody, "notes.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_note" {
|
||||
t.Fatalf("notes mention = %q, want ou_note; body: %s", got, string(objectiveBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_ObjectiveWithCategoryID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
var objectiveBody []byte
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
OnMatch: func(req *http.Request) {
|
||||
body, err := io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("read objective request body: %v", err)
|
||||
}
|
||||
objectiveBody = body
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "100",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runBatchCreateShortcut(t, f, stdout, []string{
|
||||
"+batch-create",
|
||||
"--cycle-id", "123",
|
||||
"--category-id", "7249339036661170180",
|
||||
"--input", `[{"text":"Obj 1"}]`,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := gjson.GetBytes(objectiveBody, "category_id").String(); got != "7249339036661170180" {
|
||||
t.Fatalf("category_id = %q, want 7249339036661170180; body: %s", got, string(objectiveBody))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBatchCreateExecute_APIErrorOnObjective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, batchCreateTestConfig(t))
|
||||
|
||||
394
shortcuts/okr/okr_create.go
Normal file
394
shortcuts/okr/okr_create.go
Normal file
@@ -0,0 +1,394 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
// createParams holds the parsed parameters for single-object create operations.
|
||||
type createParams struct {
|
||||
Level string
|
||||
CycleID string
|
||||
ObjectiveID string
|
||||
Style string
|
||||
Content *ContentBlock
|
||||
Notes *ContentBlock
|
||||
CategoryID string
|
||||
UserIDType string
|
||||
}
|
||||
|
||||
type createContentMultipleJSONValuesError struct{}
|
||||
|
||||
func (createContentMultipleJSONValuesError) Error() string {
|
||||
return "multiple JSON values"
|
||||
}
|
||||
|
||||
var errCreateContentMultipleJSONValues createContentMultipleJSONValuesError
|
||||
|
||||
type okrCreateRequestBody struct {
|
||||
Content *ContentBlock `json:"content"`
|
||||
Notes *ContentBlock `json:"notes,omitempty"`
|
||||
CategoryID string `json:"category_id,omitempty"`
|
||||
}
|
||||
|
||||
type okrCreateObjectiveQuery struct {
|
||||
CycleID string
|
||||
UserIDType string
|
||||
}
|
||||
|
||||
type okrCreateKeyResultQuery struct {
|
||||
ObjectiveID string
|
||||
UserIDType string
|
||||
}
|
||||
|
||||
type okrCreateObjectiveResponse struct {
|
||||
ObjectiveID string
|
||||
}
|
||||
|
||||
type okrCreateKeyResultResponse struct {
|
||||
KeyResultID string
|
||||
}
|
||||
|
||||
type okrCreateObjectiveOutput struct {
|
||||
Level string `json:"level"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
}
|
||||
|
||||
type okrCreateKeyResultOutput struct {
|
||||
Level string `json:"level"`
|
||||
ObjectiveID string `json:"objective_id"`
|
||||
KeyResultID string `json:"key_result_id"`
|
||||
}
|
||||
|
||||
func decodeCreateContentStrict(inputStr string, target interface{}, param, message string) error {
|
||||
dec := json.NewDecoder(bytes.NewReader([]byte(inputStr)))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(target); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, message, err).
|
||||
WithParam(param).
|
||||
WithCause(err)
|
||||
}
|
||||
var trailing interface{}
|
||||
if err := dec.Decode(&trailing); !errors.Is(err, io.EOF) {
|
||||
if err == nil {
|
||||
err = errCreateContentMultipleJSONValues
|
||||
}
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, message, err).
|
||||
WithParam(param).
|
||||
WithCause(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseCreateContentValue(inputStr, param, style string) (*ContentBlock, error) {
|
||||
if style == "simple" {
|
||||
var sp SemiPlainContent
|
||||
if err := decodeCreateContentStrict(inputStr, &sp, param, fmt.Sprintf("%s must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %%s", param)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(sp.Text) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s text is required and cannot be empty", param).WithParam(param)
|
||||
}
|
||||
for i, mention := range sp.Mention {
|
||||
if strings.TrimSpace(mention) == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s mention[%d] cannot be empty", param, i).WithParam(param)
|
||||
}
|
||||
}
|
||||
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s docs and images are not supported in simple style input; use richtext style or remove these fields", param).WithParam(param)
|
||||
}
|
||||
return sp.ToContentBlock(), nil
|
||||
}
|
||||
|
||||
var cb ContentBlock
|
||||
if err := decodeCreateContentStrict(inputStr, &cb, param, fmt.Sprintf("%s must be valid ContentBlock JSON: %%s", param)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(cb.Blocks) == 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s must contain at least one block", param).WithParam(param)
|
||||
}
|
||||
|
||||
hasNonEmptyParagraph := false
|
||||
for _, block := range cb.Blocks {
|
||||
if block.Paragraph != nil && len(block.Paragraph.Elements) > 0 {
|
||||
hasNonEmptyParagraph = true
|
||||
break
|
||||
}
|
||||
if block.Gallery != nil && len(block.Gallery.Images) > 0 {
|
||||
hasNonEmptyParagraph = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasNonEmptyParagraph {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s cannot be empty", param).WithParam(param)
|
||||
}
|
||||
return &cb, nil
|
||||
}
|
||||
|
||||
func projectCreateRequestBody(body okrCreateRequestBody) map[string]interface{} {
|
||||
result := map[string]interface{}{
|
||||
"content": body.Content,
|
||||
}
|
||||
if body.Notes != nil {
|
||||
result["notes"] = body.Notes
|
||||
}
|
||||
if body.CategoryID != "" {
|
||||
result["category_id"] = body.CategoryID
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func projectCreateObjectiveQuery(query okrCreateObjectiveQuery) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"cycle_id": query.CycleID,
|
||||
"user_id_type": query.UserIDType,
|
||||
}
|
||||
}
|
||||
|
||||
func projectCreateKeyResultQuery(query okrCreateKeyResultQuery) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"objective_id": query.ObjectiveID,
|
||||
"user_id_type": query.UserIDType,
|
||||
}
|
||||
}
|
||||
|
||||
func projectCreateObjectiveResponse(data map[string]interface{}) (*okrCreateObjectiveResponse, error) {
|
||||
objectiveID, ok := data["objective_id"].(string)
|
||||
if !ok || objectiveID == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "create objective response missing objective_id")
|
||||
}
|
||||
return &okrCreateObjectiveResponse{ObjectiveID: objectiveID}, nil
|
||||
}
|
||||
|
||||
func projectCreateKeyResultResponse(data map[string]interface{}) (*okrCreateKeyResultResponse, error) {
|
||||
keyResultID, ok := data["key_result_id"].(string)
|
||||
if !ok || keyResultID == "" {
|
||||
return nil, errs.NewInternalError(errs.SubtypeUnknown, "create key result response missing key_result_id")
|
||||
}
|
||||
return &okrCreateKeyResultResponse{KeyResultID: keyResultID}, nil
|
||||
}
|
||||
|
||||
// parseCreateParams parses and validates flags from runtime into request-ready parameters.
|
||||
func parseCreateParams(runtime *common.RuntimeContext) (*createParams, error) {
|
||||
p := &createParams{
|
||||
Level: runtime.Str("level"),
|
||||
CycleID: runtime.Str("cycle-id"),
|
||||
ObjectiveID: runtime.Str("objective-id"),
|
||||
Style: runtime.Str("style"),
|
||||
CategoryID: runtime.Str("category-id"),
|
||||
UserIDType: runtime.Str("user-id-type"),
|
||||
}
|
||||
|
||||
contentStr := runtime.Str("content")
|
||||
if contentStr == "" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content is required").WithParam("--content")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content, err := parseCreateContentValue(contentStr, "--content", p.Style)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Content = content
|
||||
|
||||
if notesStr := runtime.Str("notes"); notesStr != "" {
|
||||
if p.Level != "objective" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported when --level=objective").WithParam("--notes")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
notes, err := parseCreateContentValue(notesStr, "--notes", p.Style)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Notes = notes
|
||||
}
|
||||
if p.CategoryID != "" {
|
||||
if p.Level != "objective" {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id is only supported when --level=objective").WithParam("--category-id")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--category-id", p.CategoryID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if id, err := strconv.ParseInt(p.CategoryID, 10, 64); err != nil || id <= 0 {
|
||||
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--category-id must be a positive int64").WithParam("--category-id")
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// OKRCreate creates a single objective or key result.
|
||||
var OKRCreate = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+create",
|
||||
Description: "Create a single OKR objective or key result",
|
||||
Risk: "write",
|
||||
Scopes: []string{"okr:okr.content:writeonly"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "level", Desc: "create level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}},
|
||||
{Name: "cycle-id", Desc: "OKR cycle ID (required for level=objective)"},
|
||||
{Name: "objective-id", Desc: "objective ID (required for level=key-result)"},
|
||||
{Name: "style", Default: "simple", Desc: "input style for content: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
|
||||
{Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "notes", Desc: "objective notes: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
|
||||
{Name: "category-id", Desc: "objective category ID; use only when classification is requested or the tenant requires categories"},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
level := runtime.Str("level")
|
||||
if level != "objective" && level != "key-result" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
|
||||
}
|
||||
|
||||
style := runtime.Str("style")
|
||||
if style != "simple" && style != "richtext" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
|
||||
}
|
||||
|
||||
idType := runtime.Str("user-id-type")
|
||||
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
|
||||
}
|
||||
|
||||
switch level {
|
||||
case "objective":
|
||||
if runtime.Str("objective-id") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id cannot be used when --level=objective").WithParam("--objective-id")
|
||||
}
|
||||
cycleID := runtime.Str("cycle-id")
|
||||
if cycleID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id is required when --level=objective").WithParam("--cycle-id")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--cycle-id", cycleID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
|
||||
}
|
||||
case "key-result":
|
||||
if runtime.Str("cycle-id") != "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id cannot be used when --level=key-result").WithParam("--cycle-id")
|
||||
}
|
||||
objectiveID := runtime.Str("objective-id")
|
||||
if objectiveID == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id is required when --level=key-result").WithParam("--objective-id")
|
||||
}
|
||||
if err := common.RejectDangerousCharsTyped("--objective-id", objectiveID); err != nil {
|
||||
return err
|
||||
}
|
||||
if id, err := strconv.ParseInt(objectiveID, 10, 64); err != nil || id <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--objective-id must be a positive int64").WithParam("--objective-id")
|
||||
}
|
||||
}
|
||||
|
||||
_, err := parseCreateParams(runtime)
|
||||
return err
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
p, err := parseCreateParams(runtime)
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().
|
||||
POST("").
|
||||
Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error()))
|
||||
}
|
||||
|
||||
body := projectCreateRequestBody(okrCreateRequestBody{Content: p.Content, Notes: p.Notes, CategoryID: p.CategoryID})
|
||||
|
||||
if p.Level == "objective" {
|
||||
params := projectCreateObjectiveQuery(okrCreateObjectiveQuery{
|
||||
CycleID: p.CycleID,
|
||||
UserIDType: p.UserIDType,
|
||||
})
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/okr/v2/cycles/:cycle_id/objectives").
|
||||
Set("cycle_id", p.CycleID).
|
||||
Params(params).
|
||||
Body(body).
|
||||
Desc("Create OKR objective")
|
||||
}
|
||||
|
||||
params := projectCreateKeyResultQuery(okrCreateKeyResultQuery{
|
||||
ObjectiveID: p.ObjectiveID,
|
||||
UserIDType: p.UserIDType,
|
||||
})
|
||||
return common.NewDryRunAPI().
|
||||
POST("/open-apis/okr/v2/objectives/:objective_id/key_results").
|
||||
Set("objective_id", p.ObjectiveID).
|
||||
Params(params).
|
||||
Body(body).
|
||||
Desc("Create OKR key result")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
p, err := parseCreateParams(runtime)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := projectCreateRequestBody(okrCreateRequestBody{Content: p.Content, Notes: p.Notes, CategoryID: p.CategoryID})
|
||||
|
||||
if p.Level == "objective" {
|
||||
queryParams := projectCreateObjectiveQuery(okrCreateObjectiveQuery{
|
||||
CycleID: p.CycleID,
|
||||
UserIDType: p.UserIDType,
|
||||
})
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/cycles/%s/objectives", p.CycleID)
|
||||
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to create objective")
|
||||
}
|
||||
resp, err := projectCreateObjectiveResponse(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := okrCreateObjectiveOutput{
|
||||
Level: p.Level,
|
||||
ObjectiveID: resp.ObjectiveID,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created OKR objective [%s]\n", resp.ObjectiveID)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
queryParams := projectCreateKeyResultQuery(okrCreateKeyResultQuery{
|
||||
ObjectiveID: p.ObjectiveID,
|
||||
UserIDType: p.UserIDType,
|
||||
})
|
||||
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", p.ObjectiveID)
|
||||
data, err := runtime.CallAPITyped("POST", path, queryParams, body)
|
||||
if err != nil {
|
||||
return wrapOkrNetworkErr(err, "failed to create key result")
|
||||
}
|
||||
resp, err := projectCreateKeyResultResponse(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := okrCreateKeyResultOutput{
|
||||
Level: p.Level,
|
||||
ObjectiveID: p.ObjectiveID,
|
||||
KeyResultID: resp.KeyResultID,
|
||||
}
|
||||
|
||||
runtime.OutFormat(result, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Created OKR key-result [%s] under objective [%s]\n", resp.KeyResultID, p.ObjectiveID)
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
707
shortcuts/okr/okr_create_test.go
Normal file
707
shortcuts/okr/okr_create_test.go
Normal file
@@ -0,0 +1,707 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func createTestConfig(t *testing.T) *core.CliConfig {
|
||||
t.Helper()
|
||||
return &core.CliConfig{
|
||||
AppID: "test-okr-create",
|
||||
AppSecret: patchTestValue(),
|
||||
Brand: core.BrandFeishu,
|
||||
}
|
||||
}
|
||||
|
||||
func runCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
|
||||
t.Helper()
|
||||
parent := &cobra.Command{Use: "okr"}
|
||||
OKRCreate.Mount(parent, f)
|
||||
parent.SetArgs(args)
|
||||
parent.SilenceErrors = true
|
||||
parent.SilenceUsage = true
|
||||
if stdout != nil {
|
||||
stdout.Reset()
|
||||
}
|
||||
return parent.Execute()
|
||||
}
|
||||
|
||||
func runCreateShortcutWithStdin(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, stdin string, args []string) error {
|
||||
t.Helper()
|
||||
f.IOStreams.In = strings.NewReader(stdin)
|
||||
return runCreateShortcut(t, f, stdout, args)
|
||||
}
|
||||
|
||||
const (
|
||||
validCreateSimpleJSON = `{"text":"test objective","mention":["ou_123"]}`
|
||||
validCreateRichTextJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}`
|
||||
emptyCreateRichTextJSON = `{"blocks":[]}`
|
||||
blankCreateRichTextJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[]}}]}`
|
||||
validCreateObjectiveArgs1 = "+create"
|
||||
)
|
||||
|
||||
func TestCreateValidate_MissingLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
validCreateObjectiveArgs1,
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "level") {
|
||||
t.Fatalf("expected --level required error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidLevel(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "invalid",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid level error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--level" {
|
||||
t.Fatalf("expected param --level, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_MissingCycleIDForObjective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing cycle-id error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
|
||||
t.Fatalf("expected param --cycle-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidCycleID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "abc",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid cycle-id error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
|
||||
t.Fatalf("expected param --cycle-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_MissingObjectiveIDForKR(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing objective-id error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--objective-id" {
|
||||
t.Fatalf("expected param --objective-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RejectObjectiveIDForObjective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected objective-id rejection")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--objective-id" {
|
||||
t.Fatalf("expected param --objective-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RejectCycleIDForKeyResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--cycle-id", "123",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected cycle-id rejection")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--cycle-id" {
|
||||
t.Fatalf("expected param --cycle-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RejectNotesForKeyResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--notes", `{"text":"objective only notes"}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected notes rejection for key-result")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--notes" {
|
||||
t.Fatalf("expected param --notes, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RejectCategoryIDForKeyResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--category-id", "123",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected category-id rejection for key-result")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--category-id" {
|
||||
t.Fatalf("expected param --category-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_ContentAndNotesCannotBothReadStdin(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcutWithStdin(t, f, stdout, `{"text":"stdin content"}`, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", "-",
|
||||
"--notes", "-",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected duplicate stdin error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--notes" {
|
||||
t.Fatalf("expected param --notes, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stdin (-) can only be used by one flag") {
|
||||
t.Fatalf("expected duplicate stdin error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidObjectiveID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "0",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid objective-id error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected typed invalid argument error, got: %v", err)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--objective-id" {
|
||||
t.Fatalf("expected param --objective-id, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidStyle(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "invalid",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid style error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--style" {
|
||||
t.Fatalf("expected param --style, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidUserIDType(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--user-id-type", "invalid",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid user-id-type error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--user-id-type" {
|
||||
t.Fatalf("expected param --user-id-type, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_MissingContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "content") {
|
||||
t.Fatalf("expected required content error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidSimpleContentJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", "not-json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid simple json error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_EmptySimpleText(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", `{"text":" "}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected empty simple text error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_EmptySimpleMention(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", `{"text":"test","mention":[""]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected empty simple mention error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_SimpleContentRejectsDocsImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", `{"text":"test","docs":[{"title":"doc","url":"https://example.com"}],"images":["img"]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected docs/images rejection")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_SimpleContentRejectsUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "simple",
|
||||
"--content", `{"text":"test","mentions":["ou_123"]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown simple content field error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("expected unknown field error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_InvalidRichTextJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "richtext",
|
||||
"--content", "not-json",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid richtext json error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_RichTextRejectsUnknownFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "richtext",
|
||||
"--content", `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}],"mentions":["ou_123"]}`,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected unknown richtext content field error")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unknown field") {
|
||||
t.Fatalf("expected unknown field error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateValidate_EmptyRichTextContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
for _, content := range []string{emptyCreateRichTextJSON, blankCreateRichTextJSON} {
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--style", "richtext",
|
||||
"--content", content,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("expected empty richtext error for %s", content)
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) || ve.Param != "--content" {
|
||||
t.Fatalf("expected param --content, got: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDryRun_Objective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("dry-run method = %q, want POST; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.url").String(); got != "/open-apis/okr/v2/cycles/123/objectives" {
|
||||
t.Fatalf("dry-run url = %q, want objective create path; output: %s", got, output)
|
||||
}
|
||||
if gjson.Get(output, "data.api.0.params.cycle_id").String() != "123" {
|
||||
t.Fatalf("expected query params in dry-run, got: %s", output)
|
||||
}
|
||||
if gjson.Get(output, "data.api.0.params.user_id_type").String() != "open_id" {
|
||||
t.Fatalf("expected default user-id-type in dry-run, got: %s", output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(); got != "test objective" {
|
||||
t.Fatalf("dry-run content text = %q, want test objective; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_123" {
|
||||
t.Fatalf("dry-run mention user_id = %q, want ou_123; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDryRun_ObjectiveWithNotes(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--notes", `{"text":"objective notes","mention":["ou_note"]}`,
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.body.notes.blocks.0.paragraph.elements.0.text_run.text").String(); got != "objective notes" {
|
||||
t.Fatalf("dry-run notes text = %q, want objective notes; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.body.notes.blocks.0.paragraph.elements.1.mention.user_id").String(); got != "ou_note" {
|
||||
t.Fatalf("dry-run notes mention user_id = %q, want ou_note; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDryRun_ObjectiveWithCategoryID(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
"--category-id", "7249339036661170180",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.body.category_id").String(); got != "7249339036661170180" {
|
||||
t.Fatalf("dry-run category_id = %q, want 7249339036661170180; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDryRun_KeyResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "456",
|
||||
"--style", "richtext",
|
||||
"--content", validCreateRichTextJSON,
|
||||
"--user-id-type", "union_id",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if got := gjson.Get(output, "data.api.0.method").String(); got != "POST" {
|
||||
t.Fatalf("dry-run method = %q, want POST; output: %s", got, output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.url").String(); got != "/open-apis/okr/v2/objectives/456/key_results" {
|
||||
t.Fatalf("dry-run url = %q, want key result create path; output: %s", got, output)
|
||||
}
|
||||
if gjson.Get(output, "data.api.0.params.objective_id").String() != "456" {
|
||||
t.Fatalf("expected objective-id query param in dry-run, got: %s", output)
|
||||
}
|
||||
if gjson.Get(output, "data.api.0.params.user_id_type").String() != "union_id" {
|
||||
t.Fatalf("expected query params in dry-run, got: %s", output)
|
||||
}
|
||||
if got := gjson.Get(output, "data.api.0.body.content.blocks.0.paragraph.elements.0.text_run.text").String(); got != "test content" {
|
||||
t.Fatalf("dry-run richtext content = %q, want test content; output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecute_ObjectiveSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"objective_id": "1001",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
level, _ := data["level"].(string)
|
||||
if level != "objective" {
|
||||
t.Fatalf("expected level objective, got %v", data["level"])
|
||||
}
|
||||
if data["objective_id"] != "1001" {
|
||||
t.Fatalf("expected objective_id=1001, got %v", data["objective_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecute_KeyResultSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/objectives/456/key_results",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"key_result_id": "2001",
|
||||
},
|
||||
},
|
||||
})
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "key-result",
|
||||
"--objective-id", "456",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
level, _ := data["level"].(string)
|
||||
if level != "key-result" {
|
||||
t.Fatalf("expected level key-result, got %v", data["level"])
|
||||
}
|
||||
if data["key_result_id"] != "2001" {
|
||||
t.Fatalf("expected key_result_id=2001, got %v", data["key_result_id"])
|
||||
}
|
||||
if data["objective_id"] != "456" {
|
||||
t.Fatalf("expected objective_id=456, got %v", data["objective_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecute_ObjectiveAPITypedErrorPassThrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/okr/v2/cycles/123/objectives",
|
||||
Status: 400,
|
||||
Body: map[string]interface{}{
|
||||
"code": 1001001,
|
||||
"msg": "invalid parameters",
|
||||
},
|
||||
})
|
||||
err := runCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--level", "objective",
|
||||
"--cycle-id", "123",
|
||||
"--content", validCreateSimpleJSON,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected API error")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryAPI {
|
||||
t.Fatalf("expected typed API error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateExecute_KeyResultRawErrorWrappedAsNetworkError(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, createTestConfig(t))
|
||||
raw := errors.New("dial tcp: i/o timeout")
|
||||
got := wrapOkrNetworkErr(raw, "failed to create key result")
|
||||
problem, ok := errs.ProblemOf(got)
|
||||
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
|
||||
t.Fatalf("expected network transport error, got: %v", got)
|
||||
}
|
||||
if !errors.Is(got, raw) {
|
||||
t.Fatal("expected wrapped raw error to be preserved")
|
||||
}
|
||||
if stdout.String() != "" || f == nil {
|
||||
// keep the test factory referenced so the helper wiring stays exercised
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,10 @@ func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
|
||||
cycleStart := time.UnixMilli(startMs).UTC()
|
||||
cycleEnd := time.UnixMilli(endMs).UTC()
|
||||
nowUTC := now.UTC()
|
||||
// Month cycles only
|
||||
if cycleStart.AddDate(1, 0, -1) == cycleEnd {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check time range: now must be >= start and <= end
|
||||
if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) {
|
||||
@@ -78,6 +82,7 @@ func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
|
||||
return status == CycleStatusDefault || status == CycleStatusNormal
|
||||
}
|
||||
|
||||
// OKRListCycles
|
||||
var OKRListCycles = common.Shortcut{
|
||||
Service: "okr",
|
||||
Command: "+cycle-list",
|
||||
@@ -89,7 +94,9 @@ var OKRListCycles = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "user-id", Desc: "user ID", Required: true},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "time-range", Desc: "specify time range. Use Format as YYYY-MM--YYYY-MM. leave empty to fetch all user cycles."},
|
||||
{Name: "time-range", Desc: "local post-filter applied after the requested page is fetched. Format: YYYY-MM--YYYY-MM. Leave empty to keep the page unfiltered."},
|
||||
{Name: "page-size", Type: "int", Default: "100", Desc: "page size, range 1-100"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
idType := runtime.Str("user-id-type")
|
||||
@@ -110,18 +117,29 @@ var OKRListCycles = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--page-token", pageToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
params := map[string]interface{}{
|
||||
"user_id": runtime.Str("user-id"),
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"page_size": 100,
|
||||
"page_size": runtime.Int("page-size"),
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
GET("/open-apis/okr/v2/cycles").
|
||||
Params(params).
|
||||
Desc("List OKR cycles for user, paginated at 100 per page, filtered by time-range")
|
||||
Desc("List one page of OKR cycles for user; --time-range is a local post-filter on the returned page")
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
userID := runtime.Str("user-id")
|
||||
@@ -140,53 +158,35 @@ var OKRListCycles = common.Shortcut{
|
||||
hasRange = true
|
||||
}
|
||||
|
||||
// Paginated fetch of all cycles
|
||||
queryParams := map[string]interface{}{
|
||||
"user_id": userID,
|
||||
"user_id_type": userIDType,
|
||||
"page_size": "100",
|
||||
"page_size": runtime.Int("page-size"),
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
queryParams["page_token"] = pageToken
|
||||
}
|
||||
|
||||
var allCycles []Cycle
|
||||
page := 0
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if page > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
page++
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cycle Cycle
|
||||
if err := json.Unmarshal(raw, &cycle); err != nil {
|
||||
continue
|
||||
}
|
||||
allCycles = append(allCycles, cycle)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams["page_token"] = pageToken
|
||||
data, err := runtime.CallAPITyped("GET", "/open-apis/okr/v2/cycles", queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var cycle Cycle
|
||||
if err := json.Unmarshal(raw, &cycle); err != nil {
|
||||
continue
|
||||
}
|
||||
allCycles = append(allCycles, cycle)
|
||||
}
|
||||
hasMore, nextPageToken := common.PaginationMeta(data)
|
||||
|
||||
// Filter by time-range overlap
|
||||
var filtered []Cycle
|
||||
for i := range allCycles {
|
||||
@@ -212,7 +212,8 @@ var OKRListCycles = common.Shortcut{
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"cycles": respCycles,
|
||||
"total": len(respCycles),
|
||||
"has_more": hasMore,
|
||||
"page_token": nextPageToken,
|
||||
"current_active_cycles": currentActiveCycles,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
|
||||
|
||||
@@ -5,6 +5,8 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -120,6 +123,27 @@ func TestCycleListValidate_StartAfterEndTimeRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_InvalidPageSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--page-size", "101",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --page-size")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation invalid_argument problem, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok || validationErr.Param != "--page-size" {
|
||||
t.Fatalf("expected param --page-size, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListValidate_ValidNoTimeRange(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
@@ -214,6 +238,9 @@ func TestCycleListDryRun(t *testing.T) {
|
||||
if !strings.Contains(output, "/open-apis/okr/v2/cycles") {
|
||||
t.Fatalf("dry-run output should contain API path, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"page_size\": 100") {
|
||||
t.Fatalf("dry-run output should contain default page_size=100, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListDryRun_WithTimeRange(t *testing.T) {
|
||||
@@ -234,6 +261,28 @@ func TestCycleListDryRun_WithTimeRange(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCycleListDryRun_WithPagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-789",
|
||||
"--page-size", "20",
|
||||
"--page-token", "next-page",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "\"page_size\": 20") {
|
||||
t.Fatalf("dry-run output should contain page_size=20, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"page_token\": \"next-page\"") {
|
||||
t.Fatalf("dry-run output should contain page_token, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestCycleListExecute_NoCycles(t *testing.T) {
|
||||
@@ -454,9 +503,11 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
|
||||
if len(cycles) != 2 {
|
||||
t.Fatalf("cycles count = %d, want 2", len(cycles))
|
||||
}
|
||||
total, _ := data["total"].(float64)
|
||||
if int(total) != 2 {
|
||||
t.Fatalf("total = %v, want 2", total)
|
||||
if _, ok := data["total"]; ok {
|
||||
t.Fatal("total should not be present in response")
|
||||
}
|
||||
if hasMore, _ := data["has_more"].(bool); hasMore {
|
||||
t.Fatalf("has_more = %v, want false", hasMore)
|
||||
}
|
||||
|
||||
// Check current_active_cycles - should only contain cycle-active
|
||||
@@ -555,10 +606,13 @@ func TestCycleListExecute_Pagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
|
||||
|
||||
// First page
|
||||
var gotQuery url.Values
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
OnMatch: func(req *http.Request) {
|
||||
gotQuery = req.URL.Query()
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
@@ -578,38 +632,31 @@ func TestCycleListExecute_Pagination(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
// Second page
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/cycles",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": "cycle-p2",
|
||||
"start_time": "1738368000000",
|
||||
"end_time": "1743465600000",
|
||||
"cycle_status": 1,
|
||||
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runCycleListShortcut(t, f, stdout, []string{
|
||||
"+cycle-list",
|
||||
"--user-id", "ou-123",
|
||||
"--page-size", "1",
|
||||
"--page-token", "start_page",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := gotQuery.Get("page_size"); got != "1" {
|
||||
t.Fatalf("query page_size = %q, want 1", got)
|
||||
}
|
||||
if got := gotQuery.Get("page_token"); got != "start_page" {
|
||||
t.Fatalf("query page_token = %q, want start_page", got)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
cycles, _ := data["cycles"].([]interface{})
|
||||
if len(cycles) != 2 {
|
||||
t.Fatalf("cycles count = %d, want 2", len(cycles))
|
||||
if len(cycles) != 1 {
|
||||
t.Fatalf("cycles count = %d, want 1", len(cycles))
|
||||
}
|
||||
if hasMore, _ := data["has_more"].(bool); !hasMore {
|
||||
t.Fatalf("has_more = %v, want true", hasMore)
|
||||
}
|
||||
if pageToken, _ := data["page_token"].(string); pageToken != "next_page" {
|
||||
t.Fatalf("page_token = %q, want next_page", pageToken)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ var OKRListProgress = common.Shortcut{
|
||||
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
|
||||
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
|
||||
{Name: "department-id-type", Default: "open_department_id", Desc: "department ID type: department_id | open_department_id"},
|
||||
{Name: "page-size", Type: "int", Default: "100", Desc: "page size, range 1-100"},
|
||||
{Name: "page-token", Desc: "pagination token from previous response"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
targetID := runtime.Str("target-id")
|
||||
@@ -55,6 +57,14 @@ var OKRListProgress = common.Shortcut{
|
||||
if deptIDType != "department_id" && deptIDType != "open_department_id" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--department-id-type must be one of: department_id | open_department_id").WithParam("--department-id-type")
|
||||
}
|
||||
if _, err := common.ValidatePageSizeTyped(runtime, "page-size", 100, 1, 100); err != nil {
|
||||
return err
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
if err := common.RejectDangerousCharsTyped("--page-token", pageToken); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -63,7 +73,10 @@ var OKRListProgress = common.Shortcut{
|
||||
params := map[string]interface{}{
|
||||
"user_id_type": runtime.Str("user-id-type"),
|
||||
"department_id_type": runtime.Str("department-id-type"),
|
||||
"page_size": 100,
|
||||
"page_size": runtime.Int("page-size"),
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
params["page_token"] = pageToken
|
||||
}
|
||||
|
||||
switch targetType {
|
||||
@@ -91,7 +104,10 @@ var OKRListProgress = common.Shortcut{
|
||||
queryParams := map[string]interface{}{
|
||||
"user_id_type": userIDType,
|
||||
"department_id_type": deptIDType,
|
||||
"page_size": "100",
|
||||
"page_size": runtime.Int("page-size"),
|
||||
}
|
||||
if pageToken := runtime.Str("page-token"); pageToken != "" {
|
||||
queryParams["page_token"] = pageToken
|
||||
}
|
||||
|
||||
var apiPath string
|
||||
@@ -103,36 +119,29 @@ var OKRListProgress = common.Shortcut{
|
||||
}
|
||||
|
||||
var allProgress []*Progress
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var progress Progress
|
||||
if err := json.Unmarshal(raw, &progress); err != nil {
|
||||
continue
|
||||
}
|
||||
allProgress = append(allProgress, &progress)
|
||||
}
|
||||
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
if !hasMore || pageToken == "" {
|
||||
break
|
||||
}
|
||||
queryParams["page_token"] = pageToken
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped("GET", apiPath, queryParams, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
itemsRaw, _ := data["items"].([]interface{})
|
||||
for _, item := range itemsRaw {
|
||||
raw, err := json.Marshal(item)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var progress Progress
|
||||
if err := json.Unmarshal(raw, &progress); err != nil {
|
||||
continue
|
||||
}
|
||||
allProgress = append(allProgress, &progress)
|
||||
}
|
||||
hasMore, pageToken := common.PaginationMeta(data)
|
||||
|
||||
// Convert to response format
|
||||
respProgress := make([]*RespProgress, 0, len(allProgress))
|
||||
for _, p := range allProgress {
|
||||
@@ -141,7 +150,8 @@ var OKRListProgress = common.Shortcut{
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"progress_list": respProgress,
|
||||
"total": len(respProgress),
|
||||
"has_more": hasMore,
|
||||
"page_token": pageToken,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Found %d progress(es)\n", len(respProgress))
|
||||
for _, p := range respProgress {
|
||||
|
||||
@@ -5,11 +5,14 @@ package okr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -123,6 +126,28 @@ func TestProgressListValidate_InvalidDepartmentIDType(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressListValidate_InvalidPageSize(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t))
|
||||
err := runProgressListShortcut(t, f, stdout, []string{
|
||||
"+progress-list",
|
||||
"--target-id", "123",
|
||||
"--target-type", "objective",
|
||||
"--page-size", "0",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid --page-size")
|
||||
}
|
||||
problem, ok := errs.ProblemOf(err)
|
||||
if !ok || problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("expected validation invalid_argument problem, got: %v", err)
|
||||
}
|
||||
validationErr, ok := err.(*errs.ValidationError)
|
||||
if !ok || validationErr.Param != "--page-size" {
|
||||
t.Fatalf("expected param --page-size, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- DryRun tests ---
|
||||
|
||||
func TestProgressListDryRun_Objective(t *testing.T) {
|
||||
@@ -144,6 +169,9 @@ func TestProgressListDryRun_Objective(t *testing.T) {
|
||||
if !strings.Contains(output, "GET") {
|
||||
t.Fatalf("dry-run output should contain GET method, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"page_size\": 100") {
|
||||
t.Fatalf("dry-run output should contain default page_size=100, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressListDryRun_KeyResult(t *testing.T) {
|
||||
@@ -164,14 +192,41 @@ func TestProgressListDryRun_KeyResult(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressListDryRun_WithPagination(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, progressListTestConfig(t))
|
||||
err := runProgressListShortcut(t, f, stdout, []string{
|
||||
"+progress-list",
|
||||
"--target-id", "123456789",
|
||||
"--target-type", "objective",
|
||||
"--page-size", "25",
|
||||
"--page-token", "next-page",
|
||||
"--dry-run",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
output := stdout.String()
|
||||
if !strings.Contains(output, "\"page_size\": 25") {
|
||||
t.Fatalf("dry-run output should contain page_size=25, got: %s", output)
|
||||
}
|
||||
if !strings.Contains(output, "\"page_token\": \"next-page\"") {
|
||||
t.Fatalf("dry-run output should contain page_token, got: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Execute tests ---
|
||||
|
||||
func TestProgressListExecute_Success_Objective(t *testing.T) {
|
||||
t.Parallel()
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, progressListTestConfig(t))
|
||||
var gotQuery url.Values
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/okr/v2/objectives/123456789/progresses",
|
||||
OnMatch: func(req *http.Request) {
|
||||
gotQuery = req.URL.Query()
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
@@ -191,7 +246,8 @@ func TestProgressListExecute_Success_Objective(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"has_more": false,
|
||||
"has_more": true,
|
||||
"page_token": "next_page",
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -199,15 +255,32 @@ func TestProgressListExecute_Success_Objective(t *testing.T) {
|
||||
"+progress-list",
|
||||
"--target-id", "123456789",
|
||||
"--target-type", "objective",
|
||||
"--page-size", "50",
|
||||
"--page-token", "start_page",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := gotQuery.Get("page_size"); got != "50" {
|
||||
t.Fatalf("query page_size = %q, want 50", got)
|
||||
}
|
||||
if got := gotQuery.Get("page_token"); got != "start_page" {
|
||||
t.Fatalf("query page_token = %q, want start_page", got)
|
||||
}
|
||||
data := decodeEnvelope(t, stdout)
|
||||
records, _ := data["progress_list"].([]interface{})
|
||||
if len(records) != 1 {
|
||||
t.Fatalf("expected 1 progress, got %d", len(records))
|
||||
}
|
||||
if _, ok := data["total"]; ok {
|
||||
t.Fatal("total should not be present in response")
|
||||
}
|
||||
if hasMore, _ := data["has_more"].(bool); !hasMore {
|
||||
t.Fatalf("has_more = %v, want true", hasMore)
|
||||
}
|
||||
if pageToken, _ := data["page_token"].(string); pageToken != "next_page" {
|
||||
t.Fatalf("page_token = %q, want next_page", pageToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressListExecute_Success_KeyResult(t *testing.T) {
|
||||
|
||||
@@ -18,6 +18,7 @@ func Shortcuts() []common.Shortcut {
|
||||
OKRUpdateProgressRecord,
|
||||
OKRDeleteProgressRecord,
|
||||
OKRUploadImage,
|
||||
OKRCreate,
|
||||
OKRBatchCreate,
|
||||
OKRReorder,
|
||||
OKRWeight,
|
||||
|
||||
@@ -12,6 +12,12 @@ import (
|
||||
func TestShortcutsRegistration(t *testing.T) {
|
||||
convey.Convey("Shortcuts() returns all commands", t, func() {
|
||||
list := Shortcuts()
|
||||
convey.So(len(list), convey.ShouldBeGreaterThan, 0)
|
||||
commands := make([]string, 0, len(list))
|
||||
for _, shortcut := range list {
|
||||
commands = append(commands, shortcut.Command)
|
||||
}
|
||||
convey.So(commands, convey.ShouldContain, "+create")
|
||||
convey.So(commands, convey.ShouldContain, "+batch-create")
|
||||
convey.So(commands, convey.ShouldContain, "+patch")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,5 +14,8 @@ func Shortcuts() []common.Shortcut {
|
||||
SlidesReplacePages,
|
||||
SlidesScreenshot,
|
||||
SlidesXMLGet,
|
||||
SlidesHistoryList,
|
||||
SlidesHistoryRevert,
|
||||
SlidesHistoryRevertStatus,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,9 @@ var SlidesCreate = common.Shortcut{
|
||||
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
|
||||
result["revision_id"] = int(revisionID)
|
||||
}
|
||||
if issues, ok := data["issues"]; ok {
|
||||
result["issues"] = issues
|
||||
}
|
||||
|
||||
// Step 2: Add slides if provided
|
||||
if slidesStr != "" {
|
||||
@@ -182,6 +185,7 @@ var SlidesCreate = common.Shortcut{
|
||||
)
|
||||
|
||||
var slideIDs []string
|
||||
var slideIssues []map[string]interface{}
|
||||
for i, slideXML := range slides {
|
||||
slideData, err := runtime.CallAPITyped(
|
||||
"POST",
|
||||
@@ -194,13 +198,24 @@ var SlidesCreate = common.Shortcut{
|
||||
if err != nil {
|
||||
return appendSlidesProgressHint(err, fmt.Sprintf("adding slide %d/%d failed; presentation %s was created, %d slide(s) added before failure", i+1, len(slides), presentationID, i))
|
||||
}
|
||||
if sid := common.GetString(slideData, "slide_id"); sid != "" {
|
||||
sid := common.GetString(slideData, "slide_id")
|
||||
if sid != "" {
|
||||
slideIDs = append(slideIDs, sid)
|
||||
}
|
||||
if issues, ok := slideData["issues"]; ok {
|
||||
slideIssues = append(slideIssues, map[string]interface{}{
|
||||
"slide_index": i + 1,
|
||||
"slide_id": sid,
|
||||
"issues": issues,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
result["slide_ids"] = slideIDs
|
||||
result["slides_added"] = len(slideIDs)
|
||||
if len(slideIssues) > 0 {
|
||||
result["slide_issues"] = slideIssues
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -351,6 +351,56 @@ func TestSlidesCreateWithSlides(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesCreatePreservesSchemaIssues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"xml_presentation_id": "pres_issues",
|
||||
"issues": "presentation schema issue",
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_issues/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"slide_id": "slide_001",
|
||||
"issues": "slide schema issue",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesCreateShortcut(t, f, stdout, []string{
|
||||
"+create",
|
||||
"--slides", `["<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data/></slide>"]`,
|
||||
"--as", "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
data := decodeSlidesCreateEnvelope(t, stdout)
|
||||
if data["issues"] != "presentation schema issue" {
|
||||
t.Fatalf("issues = %v, want presentation schema issue", data["issues"])
|
||||
}
|
||||
slideIssues, ok := data["slide_issues"].([]interface{})
|
||||
if !ok || len(slideIssues) != 1 {
|
||||
t.Fatalf("slide_issues = %#v, want one entry", data["slide_issues"])
|
||||
}
|
||||
issue, _ := slideIssues[0].(map[string]interface{})
|
||||
if issue["slide_index"] != float64(1) || issue["slide_id"] != "slide_001" || issue["issues"] != "slide schema issue" {
|
||||
t.Fatalf("slide_issues[0] = %#v", issue)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSlidesCreateWithSlidesPartialFailure verifies error reporting when a slide fails to create.
|
||||
func TestSlidesCreateWithSlidesPartialFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
290
shortcuts/slides/slides_history.go
Normal file
290
shortcuts/slides/slides_history.go
Normal file
@@ -0,0 +1,290 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type slidesHistoryListSpec struct {
|
||||
PageSize int
|
||||
PageToken string
|
||||
}
|
||||
|
||||
type slidesHistoryRevertSpec struct {
|
||||
HistoryVersionID string
|
||||
}
|
||||
|
||||
type slidesHistoryRevertStatusSpec struct {
|
||||
TaskID string
|
||||
}
|
||||
|
||||
func parseSlidesHistoryPresentation(runtime *common.RuntimeContext) (presentationRef, error) {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return presentationRef{}, err
|
||||
}
|
||||
if ref.Kind == "wiki" {
|
||||
if err := runtime.EnsureScopes([]string{"wiki:node:read"}); err != nil {
|
||||
return presentationRef{}, err
|
||||
}
|
||||
}
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
func validateSlidesHistoryPageSize(pageSize int) error {
|
||||
if pageSize < 1 || pageSize > 20 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSlidesHistoryVersionID(historyVersionID string) error {
|
||||
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
|
||||
if err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by slides +history-list").WithParam("--history-version-id").WithCause(err)
|
||||
}
|
||||
if version <= 0 {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by slides +history-list").WithParam("--history-version-id")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func slidesHistoryListParams(spec slidesHistoryListSpec) map[string]interface{} {
|
||||
params := map[string]interface{}{
|
||||
"page_size": spec.PageSize,
|
||||
}
|
||||
if spec.PageToken != "" {
|
||||
params["page_token"] = spec.PageToken
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func slidesHistoryRevertBody(spec slidesHistoryRevertSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"history_version_id": spec.HistoryVersionID,
|
||||
}
|
||||
}
|
||||
|
||||
func slidesHistoryStatusParams(spec slidesHistoryRevertStatusSpec) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"task_id": spec.TaskID,
|
||||
}
|
||||
}
|
||||
|
||||
func slidesHistoryAPIPath(presentationID, suffix string) string {
|
||||
return fmt.Sprintf("/open-apis/slides_ai/v1/xml_presentations/%s/%s", validate.EncodePathSegment(presentationID), suffix)
|
||||
}
|
||||
|
||||
func newSlidesHistoryDryRun(ref presentationRef, desc string) (*common.DryRunAPI, string) {
|
||||
dry := common.NewDryRunAPI()
|
||||
presentationID := ref.Token
|
||||
if ref.Kind == "wiki" {
|
||||
presentationID = "<resolved_slides_token>"
|
||||
dry.Desc("2-step orchestration: resolve wiki then " + desc).
|
||||
GET("/open-apis/wiki/v2/spaces/get_node").
|
||||
Desc("[1] Resolve wiki node to slides presentation").
|
||||
Params(map[string]interface{}{"token": ref.Token})
|
||||
} else {
|
||||
dry.Desc("OpenAPI: " + desc)
|
||||
}
|
||||
return dry, presentationID
|
||||
}
|
||||
|
||||
// SlidesHistoryList lists history versions of a Slides XML presentation.
|
||||
var SlidesHistoryList = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+history-list",
|
||||
Description: "List Slides presentation history versions",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:read"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
|
||||
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseSlidesHistoryPresentation(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
return validateSlidesHistoryPageSize(runtime.Int("page-size"))
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
spec := slidesHistoryListSpec{
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
dry, presentationID := newSlidesHistoryDryRun(ref, "list Slides history versions")
|
||||
return dry.
|
||||
GET(slidesHistoryAPIPath(presentationID, "histories")).
|
||||
Params(slidesHistoryListParams(spec)).
|
||||
Set("xml_presentation_id", presentationID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec := slidesHistoryListSpec{
|
||||
PageSize: runtime.Int("page-size"),
|
||||
PageToken: strings.TrimSpace(runtime.Str("page-token")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
slidesHistoryAPIPath(presentationID, "histories"),
|
||||
slidesHistoryListParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SlidesHistoryRevert reverts a Slides XML presentation to a history version.
|
||||
var SlidesHistoryRevert = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+history-revert",
|
||||
Description: "Revert a Slides presentation to a historical version",
|
||||
Risk: "write",
|
||||
Scopes: []string{"slides:presentation:update", "slides:presentation:write_only"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "history-version-id", Desc: "history_version_id from slides +history-list to revert to", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseSlidesHistoryPresentation(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateSlidesHistoryVersionID(runtime.Str("history-version-id")); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
spec := slidesHistoryRevertSpec{
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
}
|
||||
dry, presentationID := newSlidesHistoryDryRun(ref, "revert Slides history")
|
||||
return dry.
|
||||
POST(slidesHistoryAPIPath(presentationID, "history/revert")).
|
||||
Body(slidesHistoryRevertBody(spec)).
|
||||
Set("xml_presentation_id", presentationID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec := slidesHistoryRevertSpec{
|
||||
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodPost,
|
||||
slidesHistoryAPIPath(presentationID, "history/revert"),
|
||||
nil,
|
||||
slidesHistoryRevertBody(spec),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// SlidesHistoryRevertStatus gets the status of a Slides history revert task.
|
||||
var SlidesHistoryRevertStatus = common.Shortcut{
|
||||
Service: "slides",
|
||||
Command: "+history-revert-status",
|
||||
Description: "Get Slides history revert task status",
|
||||
Risk: "read",
|
||||
Scopes: []string{"slides:presentation:read"},
|
||||
ConditionalScopes: []string{"wiki:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "presentation", Desc: "xml_presentation_id, slides URL, or wiki URL that resolves to slides", Required: true},
|
||||
{Name: "task-id", Desc: "task_id returned by slides +history-revert", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
if _, err := parseSlidesHistoryPresentation(runtime); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(runtime.Str("task-id")) == "" {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return common.NewDryRunAPI().Set("error", err.Error())
|
||||
}
|
||||
spec := slidesHistoryRevertStatusSpec{
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
dry, presentationID := newSlidesHistoryDryRun(ref, "get Slides history revert status")
|
||||
return dry.
|
||||
GET(slidesHistoryAPIPath(presentationID, "history/revert_status")).
|
||||
Params(slidesHistoryStatusParams(spec)).
|
||||
Set("xml_presentation_id", presentationID)
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
ref, err := parsePresentationRef(runtime.Str("presentation"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
presentationID, err := resolvePresentationID(runtime, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
spec := slidesHistoryRevertStatusSpec{
|
||||
TaskID: strings.TrimSpace(runtime.Str("task-id")),
|
||||
}
|
||||
|
||||
data, err := runtime.CallAPITyped(
|
||||
http.MethodGet,
|
||||
slidesHistoryAPIPath(presentationID, "history/revert_status"),
|
||||
slidesHistoryStatusParams(spec),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
runtime.OutRaw(data, nil)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
443
shortcuts/slides/slides_history_test.go
Normal file
443
shortcuts/slides/slides_history_test.go
Normal file
@@ -0,0 +1,443 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package slides
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestSlidesHistoryDeclaredScopes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
wantBase []string
|
||||
wantFull []string
|
||||
}{
|
||||
{
|
||||
name: "list",
|
||||
shortcut: SlidesHistoryList,
|
||||
wantBase: []string{"slides:presentation:read"},
|
||||
wantFull: []string{"slides:presentation:read", "wiki:node:read"},
|
||||
},
|
||||
{
|
||||
name: "revert",
|
||||
shortcut: SlidesHistoryRevert,
|
||||
wantBase: []string{"slides:presentation:update", "slides:presentation:write_only"},
|
||||
wantFull: []string{"slides:presentation:update", "slides:presentation:write_only", "wiki:node:read"},
|
||||
},
|
||||
{
|
||||
name: "status",
|
||||
shortcut: SlidesHistoryRevertStatus,
|
||||
wantBase: []string{"slides:presentation:read"},
|
||||
wantFull: []string{"slides:presentation:read", "wiki:node:read"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.shortcut.ScopesForIdentity("user"); !reflect.DeepEqual(got, tt.wantBase) {
|
||||
t.Fatalf("user preflight scopes = %#v, want %#v", got, tt.wantBase)
|
||||
}
|
||||
if got := tt.shortcut.ScopesForIdentity("bot"); !reflect.DeepEqual(got, tt.wantBase) {
|
||||
t.Fatalf("bot preflight scopes = %#v, want %#v", got, tt.wantBase)
|
||||
}
|
||||
if got := tt.shortcut.DeclaredScopesForIdentity("user"); !reflect.DeepEqual(got, tt.wantFull) {
|
||||
t.Fatalf("declared scopes = %#v, want %#v", got, tt.wantFull)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
shortcut common.Shortcut
|
||||
args []string
|
||||
param string
|
||||
wantCause bool
|
||||
}{
|
||||
{
|
||||
name: "list rejects unsupported presentation input",
|
||||
shortcut: SlidesHistoryList,
|
||||
args: []string{"+history-list", "--presentation", "tmp/wiki/wikcn123", "--as", "bot"},
|
||||
param: "--presentation",
|
||||
},
|
||||
{
|
||||
name: "list rejects invalid page size",
|
||||
shortcut: SlidesHistoryList,
|
||||
args: []string{"+history-list", "--presentation", "presHistory", "--page-size", "0", "--as", "bot"},
|
||||
param: "--page-size",
|
||||
},
|
||||
{
|
||||
name: "revert rejects non-numeric history version id",
|
||||
shortcut: SlidesHistoryRevert,
|
||||
args: []string{"+history-revert", "--presentation", "presHistory", "--history-version-id", "abc", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
wantCause: true,
|
||||
},
|
||||
{
|
||||
name: "revert rejects non-positive history version id",
|
||||
shortcut: SlidesHistoryRevert,
|
||||
args: []string{"+history-revert", "--presentation", "presHistory", "--history-version-id", "0", "--as", "bot"},
|
||||
param: "--history-version-id",
|
||||
},
|
||||
{
|
||||
name: "status rejects empty task id",
|
||||
shortcut: SlidesHistoryRevertStatus,
|
||||
args: []string{"+history-revert-status", "--presentation", "presHistory", "--task-id", "", "--as", "bot"},
|
||||
param: "--task-id",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
err := runSlidesShortcut(t, f, stdout, tt.shortcut, tt.args)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error, got nil")
|
||||
}
|
||||
_, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("error is not typed: %T %v", err, err)
|
||||
}
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error, got %T: %v", err, err)
|
||||
}
|
||||
if validationErr.Param != tt.param {
|
||||
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
|
||||
}
|
||||
if tt.wantCause && errors.Unwrap(err) == nil {
|
||||
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryDryRun(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
listCmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryList, map[string]string{
|
||||
"presentation": "presHistoryDryRun",
|
||||
"page-size": "5",
|
||||
"page-token": "page_token_1",
|
||||
})
|
||||
listDry := decodeSlidesHistoryDryRun(t, SlidesHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
|
||||
if got, want := listDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/histories"; got != want {
|
||||
t.Fatalf("list dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
|
||||
t.Fatalf("list page_size = %d, want 5", got)
|
||||
}
|
||||
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
|
||||
t.Fatalf("list page_token = %#v, want page_token_1", got)
|
||||
}
|
||||
|
||||
revertCmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryRevert, map[string]string{
|
||||
"presentation": "presHistoryDryRun",
|
||||
"history-version-id": "42",
|
||||
})
|
||||
revertDry := decodeSlidesHistoryDryRun(t, SlidesHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
|
||||
if got, want := revertDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/history/revert"; got != want {
|
||||
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("revert history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if _, ok := revertDry.API[0].Body["wait_timeout_ms"]; ok {
|
||||
t.Fatal("revert body must not contain wait_timeout_ms")
|
||||
}
|
||||
|
||||
statusCmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryRevertStatus, map[string]string{
|
||||
"presentation": "presHistoryDryRun",
|
||||
"task-id": "task_1",
|
||||
})
|
||||
statusDry := decodeSlidesHistoryDryRun(t, SlidesHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
|
||||
if got, want := statusDry.API[0].URL, "/open-apis/slides_ai/v1/xml_presentations/presHistoryDryRun/history/revert_status"; got != want {
|
||||
t.Fatalf("status dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
|
||||
t.Fatalf("status task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryDryRunWithWikiPresentation(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cmd := newSlidesHistoryRuntimeCmd(t, SlidesHistoryList, map[string]string{
|
||||
"presentation": "https://example.feishu.cn/wiki/wikcn123",
|
||||
"page-size": "20",
|
||||
})
|
||||
dry := decodeSlidesHistoryDryRun(t, SlidesHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(cmd, nil)))
|
||||
if len(dry.API) != 2 {
|
||||
t.Fatalf("api calls = %d, want 2: %#v", len(dry.API), dry.API)
|
||||
}
|
||||
if got, want := dry.API[0].URL, "/open-apis/wiki/v2/spaces/get_node"; got != want {
|
||||
t.Fatalf("wiki dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
if got := dry.API[0].Params["token"]; got != "wikcn123" {
|
||||
t.Fatalf("wiki node parameter mismatch: got %#v, want placeholder node id", got)
|
||||
}
|
||||
if got, want := dry.API[1].URL, "/open-apis/slides_ai/v1/xml_presentations/%3Cresolved_slides_token%3E/histories"; got != want {
|
||||
t.Fatalf("history dry-run URL = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryExecuteList(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
var capturedQuery url.Values
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/histories",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"entries": []interface{}{
|
||||
map[string]interface{}{
|
||||
"revision_id": float64(42),
|
||||
"history_version_id": "11",
|
||||
"edit_time": "2026-06-22T12:24:45Z",
|
||||
"type": float64(1),
|
||||
"editor_ids": []interface{}{"ou_1"},
|
||||
},
|
||||
},
|
||||
"has_more": true,
|
||||
"page_token": "page_token_2",
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesHistoryList, []string{
|
||||
"+history-list",
|
||||
"--presentation", "presHistory",
|
||||
"--page-size", "5",
|
||||
"--page-token", "page_token_1",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := capturedQuery.Get("page_size"); got != "5" {
|
||||
t.Fatalf("page_size query = %q, want 5", got)
|
||||
}
|
||||
if got := capturedQuery.Get("page_token"); got != "page_token_1" {
|
||||
t.Fatalf("page_token query = %q, want page_token_1", got)
|
||||
}
|
||||
|
||||
data := decodeSlidesHistoryEnvelope(t, stdout)
|
||||
if got := data["page_token"]; got != "page_token_2" {
|
||||
t.Fatalf("page_token = %#v, want page_token_2", got)
|
||||
}
|
||||
entries, _ := data["entries"].([]interface{})
|
||||
if len(entries) != 1 {
|
||||
t.Fatalf("entries = %#v, want one entry", data["entries"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryExecuteRevert(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
stub := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/history/revert",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"task_id": "task_1",
|
||||
"status": "running",
|
||||
"history_version_id": "42",
|
||||
"poll_after_ms": float64(10000),
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(stub)
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesHistoryRevert, []string{
|
||||
"+history-revert",
|
||||
"--presentation", "presHistory",
|
||||
"--history-version-id", "42",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
var body map[string]interface{}
|
||||
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
|
||||
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
|
||||
}
|
||||
if got := body["history_version_id"]; got != "42" {
|
||||
t.Fatalf("history_version_id = %#v, want 42", got)
|
||||
}
|
||||
if _, ok := body["wait_timeout_ms"]; ok {
|
||||
t.Fatal("revert body must not contain wait_timeout_ms")
|
||||
}
|
||||
|
||||
data := decodeSlidesHistoryEnvelope(t, stdout)
|
||||
if got := data["task_id"]; got != "task_1" {
|
||||
t.Fatalf("task_id = %#v, want task_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryExecuteRevertStatus(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
var capturedQuery url.Values
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/presHistory/history/revert_status",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"status": "done",
|
||||
"history_version_id": "11",
|
||||
},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
capturedQuery = req.URL.Query()
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesHistoryRevertStatus, []string{
|
||||
"+history-revert-status",
|
||||
"--presentation", "presHistory",
|
||||
"--task-id", "task_1",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if got := capturedQuery.Get("task_id"); got != "task_1" {
|
||||
t.Fatalf("task_id query = %q, want task_1", got)
|
||||
}
|
||||
data := decodeSlidesHistoryEnvelope(t, stdout)
|
||||
if got := data["status"]; got != "done" {
|
||||
t.Fatalf("status = %#v, want done", got)
|
||||
}
|
||||
if got := data["history_version_id"]; got != "11" {
|
||||
t.Fatalf("history_version_id = %#v, want 11", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlidesHistoryExecuteResolvesWikiPresentation(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/wiki/v2/spaces/get_node",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"node": map[string]interface{}{
|
||||
"obj_type": "slides",
|
||||
"obj_token": "presReal",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/presReal/histories",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"entries": []interface{}{},
|
||||
"has_more": false,
|
||||
"page_token": "",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := runSlidesShortcut(t, f, stdout, SlidesHistoryList, []string{
|
||||
"+history-list",
|
||||
"--presentation", "https://example.feishu.cn/wiki/wikcn123",
|
||||
"--as", "bot",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
data := decodeSlidesHistoryEnvelope(t, stdout)
|
||||
if got := data["has_more"]; got != false {
|
||||
t.Fatalf("has_more = %#v, want false", got)
|
||||
}
|
||||
}
|
||||
|
||||
type slidesHistoryDryRunOutput struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
|
||||
func newSlidesHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
|
||||
t.Helper()
|
||||
|
||||
cmd := &cobra.Command{Use: shortcut.Command}
|
||||
for _, flag := range shortcut.Flags {
|
||||
switch flag.Type {
|
||||
case "int":
|
||||
cmd.Flags().Int(flag.Name, 0, flag.Desc)
|
||||
default:
|
||||
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
|
||||
}
|
||||
}
|
||||
for name, value := range values {
|
||||
if err := cmd.Flags().Set(name, value); err != nil {
|
||||
t.Fatalf("set --%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func decodeSlidesHistoryDryRun(t *testing.T, dry *common.DryRunAPI) slidesHistoryDryRunOutput {
|
||||
t.Helper()
|
||||
|
||||
raw, err := json.Marshal(dry)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal dry-run: %v", err)
|
||||
}
|
||||
var out slidesHistoryDryRunOutput
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\nraw=%s", err, raw)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeSlidesHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
|
||||
t.Helper()
|
||||
|
||||
var envelope map[string]interface{}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
|
||||
}
|
||||
data, _ := envelope["data"].(map[string]interface{})
|
||||
if data == nil {
|
||||
t.Fatalf("missing data in envelope: %#v", envelope)
|
||||
}
|
||||
return data
|
||||
}
|
||||
@@ -139,6 +139,7 @@ type replacePageResult struct {
|
||||
NewSlideID string
|
||||
Status string
|
||||
Error string
|
||||
Issues interface{}
|
||||
RevisionID *int
|
||||
}
|
||||
|
||||
@@ -330,6 +331,9 @@ func replaceOnePage(runtime *common.RuntimeContext, presentationID string, item
|
||||
return result, err
|
||||
}
|
||||
result.NewSlideID = newSlideID
|
||||
if issues, ok := createData["issues"]; ok {
|
||||
result.Issues = issues
|
||||
}
|
||||
if rev, ok := revisionFromData(createData); ok {
|
||||
revisionID = rev
|
||||
result.RevisionID = &rev
|
||||
@@ -389,6 +393,9 @@ func replacePageResultsOutput(results []replacePageResult) []map[string]interfac
|
||||
if result.Error != "" {
|
||||
m["error"] = result.Error
|
||||
}
|
||||
if result.Issues != nil {
|
||||
m["issues"] = result.Issues
|
||||
}
|
||||
if result.RevisionID != nil {
|
||||
m["revision_id"] = *result.RevisionID
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
|
||||
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_abc/slide",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11},
|
||||
"data": map[string]interface{}{"slide_id": "new2", "revision_id": 11, "issues": "slide schema issue"},
|
||||
},
|
||||
OnMatch: func(req *http.Request) {
|
||||
requestOrder = append(requestOrder, req.Method)
|
||||
@@ -123,6 +123,9 @@ func TestReplacePagesCreatesBeforeThenDeletesOld(t *testing.T) {
|
||||
if first["old_slide_id"] != "old2" || first["new_slide_id"] != "new2" || first["status"] != "replaced" {
|
||||
t.Fatalf("result = %#v", first)
|
||||
}
|
||||
if first["issues"] != "slide schema issue" {
|
||||
t.Fatalf("result.issues = %v, want slide schema issue", first["issues"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestReplacePagesContinueOnErrorReturnsPartialFailure(t *testing.T) {
|
||||
|
||||
@@ -12,6 +12,7 @@ func Shortcuts() []common.Shortcut {
|
||||
return []common.Shortcut{
|
||||
WhiteboardUpdate,
|
||||
WhiteboardUpdateOld,
|
||||
WhiteboardExport,
|
||||
WhiteboardQuery,
|
||||
}
|
||||
}
|
||||
|
||||
728
shortcuts/whiteboard/whiteboard_export.go
Normal file
728
shortcuts/whiteboard/whiteboard_export.go
Normal file
@@ -0,0 +1,728 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
// WhiteboardExportAsPreview exports a whiteboard preview image.
|
||||
WhiteboardExportAsPreview = "preview"
|
||||
// WhiteboardExportAsSvg exports a whiteboard as SVG.
|
||||
WhiteboardExportAsSvg = "svg"
|
||||
// WhiteboardExportAsSource exports Mermaid or PlantUML source extracted from the whiteboard.
|
||||
WhiteboardExportAsSource = "source"
|
||||
// WhiteboardExportAsRaw exports the raw whiteboard node payload.
|
||||
WhiteboardExportAsRaw = "raw"
|
||||
|
||||
// Legacy output type names accepted for backward compatibility.
|
||||
WhiteboardQueryAsImage = "image"
|
||||
// WhiteboardQueryAsSvg is deprecated; use WhiteboardExportAsSvg.
|
||||
WhiteboardQueryAsSvg = WhiteboardExportAsSvg
|
||||
WhiteboardQueryAsCode = "code"
|
||||
// WhiteboardQueryAsRaw is deprecated; use WhiteboardExportAsRaw.
|
||||
WhiteboardQueryAsRaw = WhiteboardExportAsRaw
|
||||
)
|
||||
|
||||
// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
|
||||
type SyntaxType int
|
||||
|
||||
const (
|
||||
// SyntaxTypePlantUML marks PlantUML code blocks.
|
||||
SyntaxTypePlantUML SyntaxType = 1
|
||||
// SyntaxTypeMermaid marks Mermaid code blocks.
|
||||
SyntaxTypeMermaid SyntaxType = 2
|
||||
)
|
||||
|
||||
// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
|
||||
var SyntaxTypeNameMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: "plantuml",
|
||||
SyntaxTypeMermaid: "mermaid",
|
||||
}
|
||||
|
||||
// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
|
||||
var SyntaxTypeExtensionMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: ".puml",
|
||||
SyntaxTypeMermaid: ".mmd",
|
||||
}
|
||||
|
||||
// String returns the CLI-facing name for the syntax type.
|
||||
func (s SyntaxType) String() string {
|
||||
return SyntaxTypeNameMap[s]
|
||||
}
|
||||
|
||||
// ExtensionName returns the default file extension for the syntax type.
|
||||
func (s SyntaxType) ExtensionName() string {
|
||||
return SyntaxTypeExtensionMap[s]
|
||||
}
|
||||
|
||||
// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
|
||||
func (s SyntaxType) IsValid() bool {
|
||||
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
|
||||
}
|
||||
|
||||
var wbExportScopes = []string{"board:whiteboard:node:read"}
|
||||
var wbExportAuthTypes = []string{"user", "bot"}
|
||||
var wbExportFlags = []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
|
||||
{Name: "output-type", Desc: "output whiteboard as: preview | svg | source | raw.", Required: true, Enum: []string{"preview", "svg", "source", "raw"}},
|
||||
{Name: "output", Desc: "output path. It is required when --output-type preview. If not specified when --output-type svg/source/raw, it will output directly.", Required: false},
|
||||
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
|
||||
}
|
||||
|
||||
var wbQueryFlags = []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
|
||||
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true, Enum: []string{"image", "svg", "code", "raw"}},
|
||||
{Name: "output", Desc: "output path. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
|
||||
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
|
||||
}
|
||||
|
||||
func wbExportOutputType(runtime *common.RuntimeContext) (string, string) {
|
||||
normalized, ok := normalizeWhiteboardExportOutputType(runtime.Str("output-type"))
|
||||
if !ok {
|
||||
return "", "--output-type"
|
||||
}
|
||||
return normalized, "--output-type"
|
||||
}
|
||||
|
||||
func wbQueryOutputType(runtime *common.RuntimeContext) (string, string) {
|
||||
normalized, ok := normalizeLegacyWhiteboardExportOutputType(runtime.Str("output_as"))
|
||||
if !ok {
|
||||
return "", "--output_as"
|
||||
}
|
||||
return normalized, "--output_as"
|
||||
}
|
||||
|
||||
func normalizeWhiteboardExportOutputType(outputType string) (string, bool) {
|
||||
switch outputType {
|
||||
case WhiteboardExportAsPreview:
|
||||
return WhiteboardExportAsPreview, true
|
||||
case WhiteboardExportAsSvg:
|
||||
return WhiteboardExportAsSvg, true
|
||||
case WhiteboardExportAsSource:
|
||||
return WhiteboardExportAsSource, true
|
||||
case WhiteboardExportAsRaw:
|
||||
return WhiteboardExportAsRaw, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLegacyWhiteboardExportOutputType(outputType string) (string, bool) {
|
||||
switch outputType {
|
||||
case WhiteboardQueryAsImage:
|
||||
return WhiteboardExportAsPreview, true
|
||||
case WhiteboardQueryAsCode:
|
||||
return WhiteboardExportAsSource, true
|
||||
default:
|
||||
return normalizeWhiteboardExportOutputType(outputType)
|
||||
}
|
||||
}
|
||||
|
||||
func wbExportOutputTypeError(param string) *errs.ValidationError {
|
||||
if param == "--output_as" {
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--output_as flag must be one of: image | svg | code | raw",
|
||||
).WithParam("--output_as")
|
||||
}
|
||||
return errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"--output-type flag must be one of: preview | svg | source | raw",
|
||||
).WithParam("--output-type")
|
||||
}
|
||||
|
||||
func wbExportValidate(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return wbExportValidateWithOutputType(ctx, runtime, wbExportOutputType)
|
||||
}
|
||||
|
||||
func wbQueryValidate(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return wbExportValidateWithOutputType(ctx, runtime, wbQueryOutputType)
|
||||
}
|
||||
|
||||
func wbExportValidateWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) error {
|
||||
// Check if token contains control characters
|
||||
token := runtime.Str("whiteboard-token")
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", token); err != nil {
|
||||
return err
|
||||
}
|
||||
outputType, outputTypeParam := outputTypeFn(runtime)
|
||||
if outputType == "" {
|
||||
return wbExportOutputTypeError(outputTypeParam)
|
||||
}
|
||||
|
||||
out := runtime.Str("output")
|
||||
if out != "" {
|
||||
if _, err := runtime.ResolveSavePath(out); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
}
|
||||
if out == "" && outputType == WhiteboardExportAsPreview {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "need a output path to export whiteboard as preview").WithParam("--output")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wbExportDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return wbExportDryRunWithOutputType(ctx, runtime, wbExportOutputType)
|
||||
}
|
||||
|
||||
func wbQueryDryRun(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return wbExportDryRunWithOutputType(ctx, runtime, wbQueryOutputType)
|
||||
}
|
||||
|
||||
func wbExportDryRunWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) *common.DryRunAPI {
|
||||
outputType, outputTypeParam := outputTypeFn(runtime)
|
||||
token := runtime.Str("whiteboard-token")
|
||||
switch outputType {
|
||||
case WhiteboardExportAsPreview:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Export preview image of given whiteboard")
|
||||
case WhiteboardExportAsSource:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract Mermaid/Plantuml source from given whiteboard")
|
||||
case WhiteboardExportAsRaw:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract raw nodes structure from given whiteboard")
|
||||
case WhiteboardExportAsSvg:
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
|
||||
Body(map[string]string{"export_type": "svg"}).
|
||||
Desc("Export SVG of given whiteboard")
|
||||
default:
|
||||
if outputTypeParam == "--output_as" {
|
||||
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
|
||||
}
|
||||
return common.NewDryRunAPI().Desc("invalid --output-type flag, must be one of: preview | svg | source | raw")
|
||||
}
|
||||
}
|
||||
|
||||
func wbExportExecute(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return wbExportExecuteWithOutputType(ctx, runtime, wbExportOutputType)
|
||||
}
|
||||
|
||||
func wbQueryExecute(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
return wbExportExecuteWithOutputType(ctx, runtime, wbQueryOutputType)
|
||||
}
|
||||
|
||||
func wbExportExecuteWithOutputType(ctx context.Context, runtime *common.RuntimeContext, outputTypeFn func(*common.RuntimeContext) (string, string)) error {
|
||||
token := runtime.Str("whiteboard-token")
|
||||
outDir := runtime.Str("output")
|
||||
outputType, outputTypeParam := outputTypeFn(runtime)
|
||||
switch outputType {
|
||||
case WhiteboardExportAsPreview:
|
||||
return exportWhiteboardPreview(ctx, runtime, token, outDir)
|
||||
case WhiteboardExportAsSvg:
|
||||
return exportWhiteboardSvg(runtime, token, outDir)
|
||||
case WhiteboardExportAsSource:
|
||||
return exportWhiteboardCode(runtime, token, outDir)
|
||||
case WhiteboardExportAsRaw:
|
||||
return exportWhiteboardRaw(runtime, token, outDir)
|
||||
default:
|
||||
return wbExportOutputTypeError(outputTypeParam)
|
||||
}
|
||||
}
|
||||
|
||||
const WhiteboardExportDescription = "Export an existing whiteboard as preview image, SVG, source code or raw nodes structure."
|
||||
|
||||
// WhiteboardExport registers the `whiteboard +export` shortcut.
|
||||
var WhiteboardExport = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+export",
|
||||
Description: WhiteboardExportDescription,
|
||||
Risk: "read",
|
||||
Scopes: wbExportScopes,
|
||||
AuthTypes: wbExportAuthTypes,
|
||||
Flags: wbExportFlags,
|
||||
HasFormat: true,
|
||||
Validate: wbExportValidate,
|
||||
DryRun: wbExportDryRun,
|
||||
Execute: wbExportExecute,
|
||||
}
|
||||
|
||||
// WhiteboardQuery registers the hidden, backward-compatible `whiteboard +query` shortcut.
|
||||
var WhiteboardQuery = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+query",
|
||||
Description: WhiteboardExportDescription,
|
||||
Risk: "read",
|
||||
Scopes: wbExportScopes,
|
||||
AuthTypes: wbExportAuthTypes,
|
||||
Flags: wbQueryFlags,
|
||||
HasFormat: true,
|
||||
Hidden: true,
|
||||
Validate: wbQueryValidate,
|
||||
DryRun: wbQueryDryRun,
|
||||
Execute: wbQueryExecute,
|
||||
}
|
||||
|
||||
// exportReq defines the request body for whiteboard export APIs.
|
||||
type exportReq struct {
|
||||
ExportType string `json:"export_type"`
|
||||
}
|
||||
|
||||
// exportResp models the whiteboard export response envelope.
|
||||
type exportResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Content string `json:"content"`
|
||||
MimeType string `json:"mime_type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// exportWhiteboardSvg exports a whiteboard as SVG and writes it to stdout or a file.
|
||||
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
reqBody := exportReq{ExportType: "svg"}
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
|
||||
Body: reqBody,
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
|
||||
}
|
||||
|
||||
var exportData exportResp
|
||||
if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
|
||||
if exportData.Code != 0 {
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
|
||||
}
|
||||
} else if resp.StatusCode == http.StatusOK {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"svg_content": string(svgBytes),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(svgBytes))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"svg_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "File size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
|
||||
}
|
||||
// Execute API request. The preview endpoint streams raw image bytes (not a
|
||||
// JSON envelope), so classify by HTTP status: 5xx is retryable network,
|
||||
// while 4xx remains an API-side rejection.
|
||||
resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
|
||||
if err != nil {
|
||||
return wrapWbNetworkErr(err, "get whiteboard preview failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
finalPath, size, err := saveWhiteboardPreviewOutput(outDir, wbToken, runtime, resp.Header, bytes.NewReader(resp.RawBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"preview_image_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Preview image saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "Image size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type wbNodesResp struct {
|
||||
Data struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var nodes wbNodesResp
|
||||
rawNodes, _ := data["nodes"]
|
||||
if rawNodes != nil {
|
||||
var ok bool
|
||||
nodes.Data.Nodes, ok = rawNodes.([]interface{})
|
||||
if !ok {
|
||||
return nil, wbInvalidResponse("get whiteboard nodes failed: data.nodes must be an array")
|
||||
}
|
||||
}
|
||||
return &nodes, nil
|
||||
}
|
||||
|
||||
type syntaxInfo struct {
|
||||
code string
|
||||
syntaxType SyntaxType
|
||||
}
|
||||
|
||||
func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
var syntaxBlocks []syntaxInfo
|
||||
for _, node := range wbNodes.Data.Nodes {
|
||||
nodeMap, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntax, ok := nodeMap["syntax"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntaxMap, ok := syntax.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
code, _ := syntaxMap["code"].(string)
|
||||
var syntaxType SyntaxType
|
||||
switch v := syntaxMap["syntax_type"].(type) {
|
||||
case json.Number:
|
||||
// runtime.ClassifyAPIResponse decodes the response with UseNumber,
|
||||
// so numeric fields arrive as json.Number rather than float64.
|
||||
if n, err := v.Int64(); err == nil {
|
||||
syntaxType = SyntaxType(n)
|
||||
}
|
||||
case float64:
|
||||
syntaxType = SyntaxType(v)
|
||||
case SyntaxType:
|
||||
syntaxType = v
|
||||
}
|
||||
if code != "" && syntaxType.IsValid() {
|
||||
syntaxBlocks = append(syntaxBlocks, syntaxInfo{code: code, syntaxType: syntaxType})
|
||||
}
|
||||
}
|
||||
|
||||
if len(syntaxBlocks) == 0 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "no code blocks found in whiteboard",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "No code blocks found in whiteboard\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// 目前的标准操作是导出到单一文件,和 Doc 展示画板代码块采用相同的逻辑
|
||||
// 如果有需求,可以调整到导出到多个文件的模式
|
||||
if len(syntaxBlocks) > 1 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "multiple code blocks found, cannot export directly",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Multiple code blocks found, cannot export directly\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
block := syntaxBlocks[0]
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"code": block.code,
|
||||
"syntax_type": block.syntaxType.String(),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", block.code)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, block.syntaxType.ExtensionName(), wbToken, runtime, strings.NewReader(block.code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard code saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "cannot marshal whiteboard data: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(wbNodes.Data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(jsonData))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, ".json", wbToken, runtime, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard raw node structure saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
|
||||
// Step 1: Get final output path
|
||||
info, err := runtime.FileIO().Stat(outPath)
|
||||
var finalPath string
|
||||
if err == nil && info.IsDir() {
|
||||
finalPath = filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
|
||||
} else {
|
||||
// Fix extension in path
|
||||
currentExt := filepath.Ext(outPath)
|
||||
if currentExt != ext {
|
||||
if currentExt != "" {
|
||||
outPath = outPath[:len(outPath)-len(currentExt)]
|
||||
}
|
||||
outPath += ext
|
||||
}
|
||||
finalPath = outPath
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil { // double check
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
// Step 2: Check overwrite
|
||||
_, err = runtime.FileIO().Stat(finalPath)
|
||||
if err == nil {
|
||||
if !runtime.Bool("overwrite") {
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 3: Save file
|
||||
var contentType string
|
||||
switch ext {
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".jpg", ".jpeg":
|
||||
contentType = "image/jpeg"
|
||||
case ".svg":
|
||||
contentType = "image/svg+xml"
|
||||
case ".json":
|
||||
contentType = "application/json"
|
||||
case ".mmd", ".puml":
|
||||
contentType = "text/plain"
|
||||
}
|
||||
|
||||
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: contentType,
|
||||
}, data)
|
||||
if err != nil {
|
||||
return "", 0, wbSaveError(err)
|
||||
}
|
||||
|
||||
return finalPath, savResult.Size(), nil
|
||||
}
|
||||
|
||||
var whiteboardPreviewContentTypeExt = map[string]string{
|
||||
"image/jpeg": ".jpg",
|
||||
"image/png": ".png",
|
||||
}
|
||||
|
||||
func saveWhiteboardPreviewOutput(outPath, token string, runtime *common.RuntimeContext, header http.Header, data io.Reader) (string, int64, error) {
|
||||
contentType := header.Get("Content-Type")
|
||||
ext, err := whiteboardPreviewExtFromContentType(contentType)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
finalPath, err := whiteboardPreviewOutputPath(outPath, ext, token, runtime)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return saveResolvedOutputFile(finalPath, contentType, runtime, data)
|
||||
}
|
||||
|
||||
func whiteboardPreviewExtFromContentType(contentType string) (string, error) {
|
||||
mediaType, _, err := mime.ParseMediaType(contentType)
|
||||
if err != nil {
|
||||
mediaType = strings.TrimSpace(strings.Split(contentType, ";")[0])
|
||||
}
|
||||
if ext, ok := whiteboardPreviewContentTypeExt[strings.ToLower(mediaType)]; ok {
|
||||
return ext, nil
|
||||
}
|
||||
if strings.TrimSpace(contentType) == "" {
|
||||
contentType = "<empty>"
|
||||
}
|
||||
return "", errs.NewInternalError(
|
||||
errs.SubtypeInvalidResponse,
|
||||
"get whiteboard preview failed: expected image/png or image/jpeg response, got Content-Type: %s",
|
||||
contentType,
|
||||
)
|
||||
}
|
||||
|
||||
func whiteboardPreviewOutputPath(outPath, ext, token string, runtime *common.RuntimeContext) (string, error) {
|
||||
info, err := runtime.FileIO().Stat(outPath)
|
||||
if err == nil && info.IsDir() {
|
||||
finalPath := filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
return finalPath, nil
|
||||
}
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return "", errs.NewInternalError(errs.SubtypeFileIO, "cannot check output path: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
currentExt := strings.ToLower(filepath.Ext(outPath))
|
||||
if currentExt == "" || currentExt == "." {
|
||||
finalPath := strings.TrimSuffix(outPath, ".") + ext
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
return finalPath, nil
|
||||
}
|
||||
if !isWhiteboardPreviewImageExt(currentExt) {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeInvalidArgument,
|
||||
"invalid preview output extension %q; use .png, .jpg, .jpeg, a directory, or a path without extension",
|
||||
currentExt,
|
||||
).WithParam("--output")
|
||||
}
|
||||
if !whiteboardPreviewExtMatches(currentExt, ext) {
|
||||
return "", errs.NewValidationError(
|
||||
errs.SubtypeFailedPrecondition,
|
||||
"preview response is %s but output path has extension %s; use a matching extension or omit the extension",
|
||||
ext,
|
||||
currentExt,
|
||||
).WithParam("--output")
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(outPath); err != nil {
|
||||
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
return outPath, nil
|
||||
}
|
||||
|
||||
func isWhiteboardPreviewImageExt(ext string) bool {
|
||||
return ext == ".png" || ext == ".jpg" || ext == ".jpeg"
|
||||
}
|
||||
|
||||
func whiteboardPreviewExtMatches(outputExt, responseExt string) bool {
|
||||
if responseExt == ".jpg" {
|
||||
return outputExt == ".jpg" || outputExt == ".jpeg"
|
||||
}
|
||||
return outputExt == responseExt
|
||||
}
|
||||
|
||||
func saveResolvedOutputFile(finalPath, contentType string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
|
||||
_, err := runtime.FileIO().Stat(finalPath)
|
||||
if err == nil {
|
||||
if !runtime.Bool("overwrite") {
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: contentType,
|
||||
}, data)
|
||||
if err != nil {
|
||||
return "", 0, wbSaveError(err)
|
||||
}
|
||||
return finalPath, savResult.Size(), nil
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -211,6 +212,73 @@ func TestWhiteboardQuery_Validate_TypedErrors(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWhiteboardExport_Validate verifies the canonical +export flag spelling
|
||||
// and output type names while legacy +query validation remains covered above.
|
||||
func TestWhiteboardExport_Validate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
chdirTemp(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
flags map[string]string
|
||||
wantErr bool
|
||||
wantParam string
|
||||
}{
|
||||
{
|
||||
name: "valid: preview with output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output-type": "preview",
|
||||
"output": "output",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "valid: source without output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output-type": "source",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid: preview without output",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output-type": "preview",
|
||||
},
|
||||
wantErr: true,
|
||||
wantParam: "--output",
|
||||
},
|
||||
{
|
||||
name: "invalid: bad output-type value",
|
||||
flags: map[string]string{
|
||||
"whiteboard-token": "test-token-123",
|
||||
"output-type": "image",
|
||||
},
|
||||
wantErr: true,
|
||||
wantParam: "--output-type",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := WhiteboardExport.Validate(ctx, newTestRuntime(tt.flags, nil))
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("WhiteboardExport.Validate() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error is not *errs.ValidationError: %T", err)
|
||||
}
|
||||
if ve.Param != tt.wantParam {
|
||||
t.Fatalf("Param = %q, want %q", ve.Param, tt.wantParam)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardPreview_HTTPError locks the download-path failure
|
||||
// behavior: a failed preview download surfaces as a typed errs.* envelope, not
|
||||
// a flat legacy error.
|
||||
@@ -284,7 +352,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
"output": "output.png",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/download_as_image",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test...-123/download_as_image",
|
||||
},
|
||||
{
|
||||
name: "dry run code",
|
||||
@@ -293,7 +361,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
"output_as": "code",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test...-123/nodes",
|
||||
},
|
||||
{
|
||||
name: "dry run raw",
|
||||
@@ -302,7 +370,7 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
"output_as": "raw",
|
||||
},
|
||||
wantMethod: "GET",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test-token-123/nodes",
|
||||
wantPath: "/open-apis/board/v1/whiteboards/test...-123/nodes",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -313,6 +381,29 @@ func TestWhiteboardQuery_DryRun(t *testing.T) {
|
||||
if dryRun == nil {
|
||||
t.Fatalf("WhiteboardQuery.DryRun() returned nil")
|
||||
}
|
||||
var got struct {
|
||||
API []struct {
|
||||
Method string `json:"method"`
|
||||
URL string `json:"url"`
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
data, err := json.Marshal(dryRun)
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &got); err != nil {
|
||||
t.Fatalf("Unmarshal() error = %v; data=%s", err, string(data))
|
||||
}
|
||||
if len(got.API) != 1 {
|
||||
t.Fatalf("api len = %d, want 1; data=%s", len(got.API), string(data))
|
||||
}
|
||||
if got.API[0].Method != tt.wantMethod {
|
||||
t.Fatalf("method = %q, want %q; data=%s", got.API[0].Method, tt.wantMethod, string(data))
|
||||
}
|
||||
if got.API[0].URL != tt.wantPath {
|
||||
t.Fatalf("url = %q, want %q; data=%s", got.API[0].URL, tt.wantPath, string(data))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -391,6 +482,32 @@ func TestWhiteboardQuery_ShortcutRegistration(t *testing.T) {
|
||||
if len(WhiteboardQuery.Flags) == 0 {
|
||||
t.Errorf("WhiteboardQuery.Flags is empty, expected at least one flag")
|
||||
}
|
||||
if !WhiteboardQuery.Hidden {
|
||||
t.Errorf("WhiteboardQuery should be hidden because +export is the canonical command")
|
||||
}
|
||||
|
||||
// Verify WhiteboardExport is the visible canonical shortcut.
|
||||
if WhiteboardExport.Command != "+export" {
|
||||
t.Errorf("WhiteboardExport.Command = %q, want \"+export\"", WhiteboardExport.Command)
|
||||
}
|
||||
if WhiteboardExport.Service != "whiteboard" {
|
||||
t.Errorf("WhiteboardExport.Service = %q, want \"whiteboard\"", WhiteboardExport.Service)
|
||||
}
|
||||
if WhiteboardExport.Hidden {
|
||||
t.Errorf("WhiteboardExport should be visible")
|
||||
}
|
||||
if flag := shortcutFlag(WhiteboardExport, "output_as"); flag != nil {
|
||||
t.Errorf("WhiteboardExport --output_as should not be registered; got %#v", *flag)
|
||||
}
|
||||
if flag := shortcutFlag(WhiteboardExport, "output-type"); flag == nil || flag.Hidden {
|
||||
t.Errorf("WhiteboardExport --output-type should exist and be visible")
|
||||
}
|
||||
if flag := shortcutFlag(WhiteboardQuery, "output_as"); flag == nil || flag.Hidden {
|
||||
t.Errorf("WhiteboardQuery --output_as should exist and remain visible on the hidden legacy command")
|
||||
}
|
||||
if flag := shortcutFlag(WhiteboardQuery, "output-type"); flag != nil {
|
||||
t.Errorf("WhiteboardQuery --output-type should not be registered; got %#v", *flag)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSaveOutputFile verifies output saving, overwrite handling, and extension-specific paths.
|
||||
@@ -862,10 +979,11 @@ func TestExportWhiteboardPreview(t *testing.T) {
|
||||
|
||||
// Mock download preview image API response with RawBody
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake PNG image data"),
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake PNG image data"),
|
||||
ContentType: "image/png",
|
||||
})
|
||||
|
||||
args := []string{"+query", "--whiteboard-token", "test-token-preview", "--output_as", "image", "--output", "output", "--overwrite"}
|
||||
@@ -883,6 +1001,158 @@ func TestExportWhiteboardPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardPreview_UsesContentTypeExtension verifies preview image
|
||||
// downloads are saved according to the API response Content-Type rather than a
|
||||
// hard-coded PNG suffix.
|
||||
func TestExportWhiteboardPreview_UsesContentTypeExtension(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-jpeg/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake JPEG image data"),
|
||||
ContentType: "image/jpeg",
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-jpeg", "--output-type", "preview", "--output", "output", "--overwrite"}
|
||||
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat("output.png"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("output.png should not exist when response Content-Type is image/jpeg, stat err=%v", err)
|
||||
}
|
||||
data, err := os.ReadFile("output.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "fake JPEG image data" {
|
||||
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview_RejectsNonImageContentTypeWithoutSiblingOverwrite(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
if err := os.WriteFile("report.html", []byte("keep me"), 0644); err != nil {
|
||||
t.Fatalf("WriteFile() error: %v", err)
|
||||
}
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-html/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("<html>bad gateway</html>"),
|
||||
ContentType: "text/html; charset=utf-8",
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-html", "--output-type", "preview", "--output", "report.png", "--overwrite"}
|
||||
err := runShortcut(t, WhiteboardExport, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-image preview response")
|
||||
}
|
||||
assertInvalidResponse(t, err)
|
||||
|
||||
data, readErr := os.ReadFile("report.html")
|
||||
if readErr != nil {
|
||||
t.Fatalf("ReadFile() error: %v", readErr)
|
||||
}
|
||||
if string(data) != "keep me" {
|
||||
t.Fatalf("report.html was overwritten: %q", string(data))
|
||||
}
|
||||
if _, statErr := os.Stat("report.png"); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("report.png should not be written on invalid response, stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview_IgnoresContentDispositionExtension(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-disposition/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake JPEG image data"),
|
||||
Headers: http.Header{
|
||||
"Content-Type": []string{"image/jpeg"},
|
||||
"Content-Disposition": []string{`attachment; filename="payload.sh"`},
|
||||
},
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-disposition", "--output-type", "preview", "--output", "output", "--overwrite"}
|
||||
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat("output.sh"); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("output.sh should not be created from Content-Disposition, stat err=%v", err)
|
||||
}
|
||||
data, err := os.ReadFile("output.jpg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "fake JPEG image data" {
|
||||
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview_RejectsMismatchedExplicitExtension(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-mismatch/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake JPEG image data"),
|
||||
ContentType: "image/jpeg",
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-mismatch", "--output-type", "preview", "--output", "report.png", "--overwrite"}
|
||||
err := runShortcut(t, WhiteboardExport, args, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for mismatched explicit extension")
|
||||
}
|
||||
var ve *errs.ValidationError
|
||||
if !errors.As(err, &ve) {
|
||||
t.Fatalf("error is not *errs.ValidationError: %T (%v)", err, err)
|
||||
}
|
||||
if ve.Subtype != errs.SubtypeFailedPrecondition || ve.Param != "--output" {
|
||||
t.Fatalf("validation details = subtype %q param %q, want %q --output", ve.Subtype, ve.Param, errs.SubtypeFailedPrecondition)
|
||||
}
|
||||
if _, statErr := os.Stat("report.jpg"); !errors.Is(statErr, os.ErrNotExist) {
|
||||
t.Fatalf("report.jpg should not be created when explicit path mismatches, stat err=%v", statErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportWhiteboardPreview_AllowsMatchingExplicitExtension(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
chdirTemp(t)
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/board/v1/whiteboards/test-token-preview-matching/download_as_image",
|
||||
Status: 200,
|
||||
RawBody: []byte("fake JPEG image data"),
|
||||
ContentType: "image/jpeg",
|
||||
})
|
||||
|
||||
args := []string{"+export", "--whiteboard-token", "test-token-preview-matching", "--output-type", "preview", "--output", "report.jpeg", "--overwrite"}
|
||||
if err := runShortcut(t, WhiteboardExport, args, factory, stdout); err != nil {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
data, err := os.ReadFile("report.jpeg")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile() error: %v", err)
|
||||
}
|
||||
if string(data) != "fake JPEG image data" {
|
||||
t.Fatalf("image content = %q, want %q", string(data), "fake JPEG image data")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExportWhiteboardRaw_EmptyNodes verifies raw export reports empty whiteboards.
|
||||
func TestExportWhiteboardRaw_EmptyNodes(t *testing.T) {
|
||||
factory, stdout, reg := newExecuteFactory(t)
|
||||
@@ -1522,3 +1792,12 @@ func chdirTemp(t *testing.T) {
|
||||
}
|
||||
t.Cleanup(func() { os.Chdir(orig) })
|
||||
}
|
||||
|
||||
func shortcutFlag(shortcut common.Shortcut, name string) *common.Flag {
|
||||
for i := range shortcut.Flags {
|
||||
if shortcut.Flags[i].Name == name {
|
||||
return &shortcut.Flags[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,494 +0,0 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
package whiteboard
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
// WhiteboardQueryAsImage exports a whiteboard preview image.
|
||||
WhiteboardQueryAsImage = "image"
|
||||
// WhiteboardQueryAsSvg exports a whiteboard as SVG.
|
||||
WhiteboardQueryAsSvg = "svg"
|
||||
// WhiteboardQueryAsCode exports Mermaid or PlantUML source extracted from the whiteboard.
|
||||
WhiteboardQueryAsCode = "code"
|
||||
// WhiteboardQueryAsRaw exports the raw whiteboard node payload.
|
||||
WhiteboardQueryAsRaw = "raw"
|
||||
)
|
||||
|
||||
// SyntaxType identifies the diagram syntax extracted from whiteboard code blocks.
|
||||
type SyntaxType int
|
||||
|
||||
const (
|
||||
// SyntaxTypePlantUML marks PlantUML code blocks.
|
||||
SyntaxTypePlantUML SyntaxType = 1
|
||||
// SyntaxTypeMermaid marks Mermaid code blocks.
|
||||
SyntaxTypeMermaid SyntaxType = 2
|
||||
)
|
||||
|
||||
// SyntaxTypeNameMap maps whiteboard syntax types to their CLI output names.
|
||||
var SyntaxTypeNameMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: "plantuml",
|
||||
SyntaxTypeMermaid: "mermaid",
|
||||
}
|
||||
|
||||
// SyntaxTypeExtensionMap maps whiteboard syntax types to their default file extensions.
|
||||
var SyntaxTypeExtensionMap = map[SyntaxType]string{
|
||||
SyntaxTypePlantUML: ".puml",
|
||||
SyntaxTypeMermaid: ".mmd",
|
||||
}
|
||||
|
||||
// String returns the CLI-facing name for the syntax type.
|
||||
func (s SyntaxType) String() string {
|
||||
return SyntaxTypeNameMap[s]
|
||||
}
|
||||
|
||||
// ExtensionName returns the default file extension for the syntax type.
|
||||
func (s SyntaxType) ExtensionName() string {
|
||||
return SyntaxTypeExtensionMap[s]
|
||||
}
|
||||
|
||||
// IsValid reports whether the syntax type is one of the supported whiteboard code syntaxes.
|
||||
func (s SyntaxType) IsValid() bool {
|
||||
return s == SyntaxTypePlantUML || s == SyntaxTypeMermaid
|
||||
}
|
||||
|
||||
// WhiteboardQuery registers the `whiteboard +query` shortcut.
|
||||
var WhiteboardQuery = common.Shortcut{
|
||||
Service: "whiteboard",
|
||||
Command: "+query",
|
||||
Description: "Query a existing whiteboard, export it as preview image or raw nodes structure.",
|
||||
Risk: "read",
|
||||
Scopes: []string{"board:whiteboard:node:read"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard. You will need read permission to download preview image.", Required: true},
|
||||
{Name: "output_as", Desc: "output whiteboard as: image | svg | code | raw.", Required: true},
|
||||
{Name: "output", Desc: "output directory. It is required when output as image. If not specified when --output_as svg/code/raw, it will output directly.", Required: false},
|
||||
{Name: "overwrite", Desc: "overwrite existing file if it exists", Required: false, Type: "bool"},
|
||||
},
|
||||
HasFormat: true,
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// Check if token contains control characters
|
||||
token := runtime.Str("whiteboard-token")
|
||||
if err := common.RejectDangerousCharsTyped("--whiteboard-token", token); err != nil {
|
||||
return err
|
||||
}
|
||||
out := runtime.Str("output")
|
||||
if out != "" {
|
||||
if _, err := runtime.ResolveSavePath(out); err != nil {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
}
|
||||
if out == "" && runtime.Str("output_as") == WhiteboardQueryAsImage {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "need a output directory to query whiteboard as image").WithParam("--output")
|
||||
}
|
||||
|
||||
as := runtime.Str("output_as")
|
||||
if as != WhiteboardQueryAsImage && as != WhiteboardQueryAsSvg && as != WhiteboardQueryAsCode && as != WhiteboardQueryAsRaw {
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
as := runtime.Str("output_as")
|
||||
token := runtime.Str("whiteboard-token")
|
||||
switch as {
|
||||
case WhiteboardQueryAsImage:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Export preview image of given whiteboard")
|
||||
case WhiteboardQueryAsCode:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract Mermaid/Plantuml code from given whiteboard")
|
||||
case WhiteboardQueryAsRaw:
|
||||
return common.NewDryRunAPI().
|
||||
GET(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))).
|
||||
Desc("Extract raw nodes structure from given whiteboard")
|
||||
case WhiteboardQueryAsSvg:
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", common.MaskToken(url.PathEscape(token)))).
|
||||
Body(map[string]string{"export_type": "svg"}).
|
||||
Desc("Export SVG of given whiteboard")
|
||||
default:
|
||||
return common.NewDryRunAPI().Desc("invalid --output_as flag, must be one of: image | svg | code | raw")
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
// 构建 API 请求
|
||||
token := runtime.Str("whiteboard-token")
|
||||
outDir := runtime.Str("output")
|
||||
as := runtime.Str("output_as")
|
||||
switch as {
|
||||
case WhiteboardQueryAsImage:
|
||||
return exportWhiteboardPreview(ctx, runtime, token, outDir)
|
||||
case WhiteboardQueryAsSvg:
|
||||
return exportWhiteboardSvg(runtime, token, outDir)
|
||||
case WhiteboardQueryAsCode:
|
||||
return exportWhiteboardCode(runtime, token, outDir)
|
||||
case WhiteboardQueryAsRaw:
|
||||
return exportWhiteboardRaw(runtime, token, outDir)
|
||||
default:
|
||||
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--output_as flag must be one of: image | svg | code | raw").WithParam("--output_as")
|
||||
}
|
||||
|
||||
},
|
||||
}
|
||||
|
||||
// exportReq defines the request body for whiteboard export APIs.
|
||||
type exportReq struct {
|
||||
ExportType string `json:"export_type"`
|
||||
}
|
||||
|
||||
// exportResp models the whiteboard export response envelope.
|
||||
type exportResp struct {
|
||||
Code int `json:"code"`
|
||||
Msg string `json:"msg"`
|
||||
Data struct {
|
||||
Content string `json:"content"`
|
||||
MimeType string `json:"mime_type"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// exportWhiteboardSvg exports a whiteboard as SVG and writes it to stdout or a file.
|
||||
func exportWhiteboardSvg(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
reqBody := exportReq{ExportType: "svg"}
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodPost,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/export", url.PathEscape(wbToken)),
|
||||
Body: reqBody,
|
||||
}
|
||||
|
||||
resp, err := runtime.DoAPI(req)
|
||||
if err != nil {
|
||||
return wrapWbNetworkErr(err, "export whiteboard svg failed: %v", err)
|
||||
}
|
||||
|
||||
var exportData exportResp
|
||||
if err := json.Unmarshal(resp.RawBody, &exportData); err == nil {
|
||||
if exportData.Code != 0 {
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "export whiteboard svg failed: %s", exportData.Msg).WithCode(exportData.Code)
|
||||
}
|
||||
} else if resp.StatusCode == http.StatusOK {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "parse export response failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "export whiteboard svg failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
svgBytes, err := base64.StdEncoding.DecodeString(exportData.Data.Content)
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "decode svg base64 failed: %v", err).WithCause(err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"svg_content": string(svgBytes),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(svgBytes))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, size, err := saveOutputFile(outDir, ".svg", wbToken, runtime, bytes.NewReader(svgBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"svg_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "SVG saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "File size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportWhiteboardPreview(ctx context.Context, runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
req := &larkcore.ApiReq{
|
||||
HttpMethod: http.MethodGet,
|
||||
ApiPath: fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/download_as_image", url.PathEscape(wbToken)),
|
||||
}
|
||||
// Execute API request. The preview endpoint streams raw image bytes (not a
|
||||
// JSON envelope), so classify by HTTP status: 5xx is retryable network,
|
||||
// while 4xx remains an API-side rejection.
|
||||
resp, err := runtime.DoAPI(req, larkcore.WithFileDownload())
|
||||
if err != nil {
|
||||
return wrapWbNetworkErr(err, "get whiteboard preview failed: %v", err)
|
||||
}
|
||||
if resp.StatusCode >= 400 {
|
||||
body := common.TruncateStr(strings.TrimSpace(string(resp.RawBody)), 500)
|
||||
if resp.StatusCode >= 500 {
|
||||
return errs.NewNetworkError(errs.SubtypeNetworkServer, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode).
|
||||
WithRetryable()
|
||||
}
|
||||
subtype := errs.SubtypeUnknown
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
subtype = errs.SubtypeNotFound
|
||||
}
|
||||
return errs.NewAPIError(subtype, "get whiteboard preview failed: HTTP %d: %s", resp.StatusCode, body).
|
||||
WithCode(resp.StatusCode)
|
||||
}
|
||||
|
||||
finalPath, size, err := saveOutputFile(outDir, ".png", wbToken, runtime, bytes.NewReader(resp.RawBody))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"preview_image_path": finalPath,
|
||||
"size_bytes": size,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Preview image saved to %s\n", finalPath)
|
||||
fmt.Fprintf(w, "Image size: %d bytes", size)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
type wbNodesResp struct {
|
||||
Data struct {
|
||||
Nodes []interface{} `json:"nodes"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func fetchWhiteboardNodes(runtime *common.RuntimeContext, wbToken string) (*wbNodesResp, error) {
|
||||
data, err := runtime.CallAPITyped(http.MethodGet, fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(wbToken)), nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var nodes wbNodesResp
|
||||
rawNodes, _ := data["nodes"]
|
||||
if rawNodes != nil {
|
||||
var ok bool
|
||||
nodes.Data.Nodes, ok = rawNodes.([]interface{})
|
||||
if !ok {
|
||||
return nil, wbInvalidResponse("get whiteboard nodes failed: data.nodes must be an array")
|
||||
}
|
||||
}
|
||||
return &nodes, nil
|
||||
}
|
||||
|
||||
type syntaxInfo struct {
|
||||
code string
|
||||
syntaxType SyntaxType
|
||||
}
|
||||
|
||||
func exportWhiteboardCode(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
var syntaxBlocks []syntaxInfo
|
||||
for _, node := range wbNodes.Data.Nodes {
|
||||
nodeMap, ok := node.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntax, ok := nodeMap["syntax"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
syntaxMap, ok := syntax.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
code, _ := syntaxMap["code"].(string)
|
||||
var syntaxType SyntaxType
|
||||
switch v := syntaxMap["syntax_type"].(type) {
|
||||
case json.Number:
|
||||
// runtime.ClassifyAPIResponse decodes the response with UseNumber,
|
||||
// so numeric fields arrive as json.Number rather than float64.
|
||||
if n, err := v.Int64(); err == nil {
|
||||
syntaxType = SyntaxType(n)
|
||||
}
|
||||
case float64:
|
||||
syntaxType = SyntaxType(v)
|
||||
case SyntaxType:
|
||||
syntaxType = v
|
||||
}
|
||||
if code != "" && syntaxType.IsValid() {
|
||||
syntaxBlocks = append(syntaxBlocks, syntaxInfo{code: code, syntaxType: syntaxType})
|
||||
}
|
||||
}
|
||||
|
||||
if len(syntaxBlocks) == 0 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "no code blocks found in whiteboard",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "No code blocks found in whiteboard\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
// 目前的标准操作是导出到单一文件,和 Doc 展示画板代码块采用相同的逻辑
|
||||
// 如果有需求,可以调整到导出到多个文件的模式
|
||||
if len(syntaxBlocks) > 1 {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "multiple code blocks found, cannot export directly",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Multiple code blocks found, cannot export directly\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
block := syntaxBlocks[0]
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"code": block.code,
|
||||
"syntax_type": block.syntaxType.String(),
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", block.code)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, block.syntaxType.ExtensionName(), wbToken, runtime, strings.NewReader(block.code))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard code saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func exportWhiteboardRaw(runtime *common.RuntimeContext, wbToken, outDir string) error {
|
||||
wbNodes, err := fetchWhiteboardNodes(runtime, wbToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if wbNodes == nil || wbNodes.Data.Nodes == nil {
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"msg": "whiteboard is empty",
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard is empty\n")
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
jsonData, err := json.MarshalIndent(wbNodes.Data, "", " ")
|
||||
if err != nil {
|
||||
return errs.NewInternalError(errs.SubtypeInvalidResponse, "cannot marshal whiteboard data: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
if outDir == "" {
|
||||
runtime.OutFormat(wbNodes.Data, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", string(jsonData))
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
finalPath, _, err := saveOutputFile(outDir, ".json", wbToken, runtime, bytes.NewReader(jsonData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
runtime.OutFormat(map[string]interface{}{
|
||||
"output_path": finalPath,
|
||||
}, nil, func(w io.Writer) {
|
||||
fmt.Fprintf(w, "Whiteboard raw node structure saved to %s\n", finalPath)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func saveOutputFile(outPath, ext, token string, runtime *common.RuntimeContext, data io.Reader) (string, int64, error) {
|
||||
// Step 1: Get final output path
|
||||
info, err := runtime.FileIO().Stat(outPath)
|
||||
var finalPath string
|
||||
if err == nil && info.IsDir() {
|
||||
finalPath = filepath.Join(outPath, fmt.Sprintf("whiteboard_%s%s", token, ext))
|
||||
} else {
|
||||
// Fix extension in path
|
||||
currentExt := filepath.Ext(outPath)
|
||||
if currentExt != ext {
|
||||
if currentExt != "" {
|
||||
outPath = outPath[:len(outPath)-len(currentExt)]
|
||||
}
|
||||
outPath += ext
|
||||
}
|
||||
finalPath = outPath
|
||||
}
|
||||
if _, err := runtime.ResolveSavePath(finalPath); err != nil { // double check
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid output path: %s", err).WithParam("--output").WithCause(err)
|
||||
}
|
||||
|
||||
// Step 2: Check overwrite
|
||||
_, err = runtime.FileIO().Stat(finalPath)
|
||||
if err == nil {
|
||||
if !runtime.Bool("overwrite") {
|
||||
return "", 0, errs.NewValidationError(errs.SubtypeInvalidArgument, "file already exists: %s (use --overwrite to overwrite)", finalPath).WithParam("--overwrite")
|
||||
}
|
||||
} else if !os.IsNotExist(err) {
|
||||
return "", 0, errs.NewInternalError(errs.SubtypeFileIO, "cannot check file existence: %s", err).WithCause(err)
|
||||
}
|
||||
|
||||
// Step 3: Save file
|
||||
var contentType string
|
||||
switch ext {
|
||||
case ".png":
|
||||
contentType = "image/png"
|
||||
case ".svg":
|
||||
contentType = "image/svg+xml"
|
||||
case ".json":
|
||||
contentType = "application/json"
|
||||
case ".mmd", ".puml":
|
||||
contentType = "text/plain"
|
||||
}
|
||||
|
||||
savResult, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
|
||||
ContentType: contentType,
|
||||
}, data)
|
||||
if err != nil {
|
||||
return "", 0, wbSaveError(err)
|
||||
}
|
||||
|
||||
return finalPath, savResult.Size(), nil
|
||||
}
|
||||
@@ -255,6 +255,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
want := []string{
|
||||
"+update",
|
||||
"+export",
|
||||
"+query",
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ metadata:
|
||||
- 表名、字段名、视图名、workflow 配置中的名称必须来自真实返回;跨表场景还要读取目标表结构。
|
||||
- 删除、角色更新、字段更新等高风险操作遵循 CLI 的 confirmation gate;目标不明确时先用 get/list 消歧。
|
||||
- 批量写入单批最多 200 条;连续写同一表时串行执行,遇到 `1254291` 按短暂等待后重试处理。
|
||||
- `+record-batch-update` 是“同值批量更新”:同一份 patch 应用到全部 `record_id_list`,不要拿它做逐行不同值映射。
|
||||
- `+record-batch-update` 使用 `update_records`,按 `record_id -> fields` 映射逐条提交字段值。
|
||||
- select/multiselect 写入未知选项可能触发平台新增选项;不是要新增时,先用 `+field-list` 或 `+field-search-options` 确认可选值。
|
||||
|
||||
## 表单与视图细节
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- `--json` 必须是 JSON 对象。
|
||||
- `+record-upsert`:顶层直接传字段映射:`{"字段名或字段ID": CellValue}`。
|
||||
- `+record-batch-create`:`rows` 是 `CellValue[][]`,列顺序由 `fields` 决定。
|
||||
- `+record-batch-update`:`patch` 是 `Map<FieldNameOrID, CellValue>`,同一份 `patch` 会应用到所有 `record_id_list`。
|
||||
- `+record-batch-update`:使用 `update_records`,其每个 value 都是 `Map<FieldNameOrID, CellValue>`。
|
||||
- 一次 payload 里同一字段只用一种 key(字段名或字段 ID),不要重复。
|
||||
- 写入前先 `+field-list` 获取字段 `type/style/multiple`,再构造值。
|
||||
- 需要清空字段时优先传 `null`(字段允许清空时)。
|
||||
|
||||
@@ -87,16 +87,20 @@ POST /open-apis/base/v3/bases/:base_token/tables/:table_id/fields
|
||||
## 返回重点
|
||||
|
||||
- 返回 `field` 和 `created: true`。
|
||||
- 如果返回 `field_get_recommended:false` 且 `next_step:"done"`,表示本次是简单字段创建,通常不需要立刻执行 `+field-get`。
|
||||
- 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按 `verification_hint` 读回字段;`formula`、`lookup`、`link`、`auto_number` 等计算、关联或生成型字段更适合读回确认服务端最终结构。
|
||||
|
||||
## 工作流
|
||||
|
||||
|
||||
1. formula / lookup 字段必须先阅读对应指南;没读之前不要直接创建。
|
||||
2. 创建简单字段时,优先相信命令返回;只有用户要求精确核对额外属性,或返回建议读回时,才继续执行 `+field-get`。
|
||||
|
||||
## 坑点
|
||||
|
||||
- ⚠️ 这是写入操作,执行前必须确认。
|
||||
- ⚠️ 当 `type` 是 `formula` 或 `lookup` 时,先读对应 guide,再创建。
|
||||
- ⚠️ 不要把“每次创建后都 `+field-get`”当作固定流程;按返回里的 `field_get_recommended` 和 `next_step` 决定是否读回。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -180,11 +180,11 @@
|
||||
|
||||
支持字段:`icon`、`min`、`max`
|
||||
|
||||
默认值 / 约束:
|
||||
默认值 / 已知平台范围:
|
||||
- `icon` 默认 `star`
|
||||
- `icon` 可用:`star`、`heart`、`thumbsup`、`fire`、`smile`、`lightning`、`flower`、`number`
|
||||
- `min` 取值 `0..1`,默认 `1`
|
||||
- `max` 取值 `1..10`,默认 `5`
|
||||
- `max` 默认 `5`;常见或已文档化的范围为 `1..10`,但 CLI 不强制上限为 `10`。如果用户明确需要更大评分范围,优先确认平台能力或用 `+field-create/update --dry-run` 检查请求形状;平台拒绝后再建议改用普通数字或进度字段。
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -419,7 +419,7 @@
|
||||
|
||||
### 3.11 auto_number
|
||||
|
||||
自动编号字段;不写 `style.rules` 时使用默认规则:`NO.001`。
|
||||
自动编号字段;创建时不写 `style.rules` 会使用默认规则:`NO.001`。更新已有自动编号字段时应显式提交目标 `style.rules`,因为 `+field-update` 会把新的编号规则重新应用到已有编号。
|
||||
|
||||
最小写法:
|
||||
|
||||
@@ -512,7 +512,7 @@
|
||||
## 4. 创建与更新
|
||||
|
||||
- `+field-create`:按目标字段配置直接构造 `--json`。
|
||||
- `+field-update`:使用同样的 JSON 结构,但语义是 `PUT`;建议先 `+field-get`,再按目标完整状态提交,并带 `--yes`。
|
||||
- `+field-update`:使用同样的 JSON 结构,但语义是 `PUT`;建议先 `+field-get`,再按目标完整状态提交,并带 `--yes`。当 `type` 是 `auto_number` 时,更新编号规则本身就会把新规则应用到已有编号,无需额外参数,也不要在 JSON 里塞额外的底层实现参数。
|
||||
|
||||
## 5. 暂不支持字段
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ lark-cli base +field-update \
|
||||
--field-id <field_id> \
|
||||
--json '{"name":"负责人","type":"user","multiple":false,"default_value":null,"description":"用于标记记录的直接负责人"}' \
|
||||
--yes
|
||||
|
||||
lark-cli base +field-update \
|
||||
--base-token <base_token> \
|
||||
--table-id <table_id> \
|
||||
--field-id <field_id> \
|
||||
--json '{"name":"编号","type":"auto_number","style":{"rules":[{"type":"text","text":"TASK-"},{"type":"created_time","date_format":"yyyyMM"},{"type":"text","text":"-"},{"type":"incremental_number","length":4}]}}' \
|
||||
--yes
|
||||
```
|
||||
|
||||
## 参数
|
||||
@@ -42,6 +49,8 @@ lark-cli base +field-update \
|
||||
PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
```
|
||||
|
||||
当 `--json.type` 是 `auto_number` 时,仍然走同一个 v3 字段更新接口:更新自动编号规则后,接口现状就会把新规则应用到已有编号(这是接口默认行为,只是 agent 通常不知道),因此**不需要**任何额外开关或参数。只需要正常提交目标自动编号字段定义即可;如果用户要求“将修改用于已有编号”,直接执行这次 `+field-update` 就能达到效果,不要在 `--json` 里额外添加任何参数去“触发”重排。
|
||||
|
||||
## JSON 值规范
|
||||
|
||||
- `--json` 必须是 **JSON 对象**,顶层直接传字段定义。
|
||||
@@ -52,6 +61,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
- `link` 更新限制:
|
||||
- 不能把非 `link` 字段改成 `link`,也不能把 `link` 改成非 `link`。
|
||||
- 现有 `link` 字段的 `bidirectional` 不能改。
|
||||
- `auto_number` 更新的 `style.rules` 支持 `text`、`created_time`、`incremental_number`。
|
||||
|
||||
**推荐更新示例**
|
||||
|
||||
@@ -83,13 +93,18 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
## 返回重点
|
||||
|
||||
- 返回 `field` 和 `updated: true`。
|
||||
- `updated:true` 只表示更新请求成功,不表示字段结构、已有记录值或下游能力已经完成验证。`+field-update` 无法知道更新前的字段类型,因此成功响应会推荐执行 `+field-get`;若发生类型转换,还要抽样读取记录值。
|
||||
- 如果响应中的 `field.type` 与提交的 `type` 不一致,必须把它当作待核验的类型不匹配;不能返回完成态,也不能只根据其中任一类型推断更新成功。
|
||||
- 如果 API 报告本次更新没有产生任何变更(no-op),命令会如实返回该错误;这通常说明目标字段已是期望状态,不要机械重试同一份 `+field-update`。需要确认当前字段完整状态时执行 `+field-get`。
|
||||
- 如果返回 `field_get_recommended:true` 或 `next_step:"field_get"`,按提示读回字段;`auto_number` 更新后还应抽样读记录值确认编号已按新规则生成。
|
||||
|
||||
## 工作流
|
||||
|
||||
|
||||
1. 建议先用 `+field-get` 拉现状,再做最小化修改。
|
||||
2. `formula/lookup` 类型更新前先阅读对应指南。
|
||||
3. 如果这次更新会改变字段 `type` 先按下方“字段类型变更规则”判断能否执行。如果不修改 `type`,大多数场景都相对安全。
|
||||
3. 如果更新 `auto_number`,理解为“更新编号规则,同时把新规则应用到已有编号”;执行后按返回提示读回字段并在必要时抽样记录值。
|
||||
4. 如果这次更新会改变字段 `type` 先按下方“字段类型变更规则”判断能否执行。如果不修改 `type`,大多数场景都相对安全。
|
||||
|
||||
## 字段类型变更规则
|
||||
|
||||
@@ -155,6 +170,7 @@ PUT /open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id
|
||||
### 完成态验证
|
||||
|
||||
- `FieldReadback`: 读回字段结构,确认 `type` / `multiple` / `style` / `options`
|
||||
- `NoopReadback`: `+field-update` 返回 no-op 错误时,只能说明 API 报告没有产生变更;可以跳过重复 update,但不能替代 `FieldReadback`
|
||||
- `ValueReadback`: 抽样读回转换后的单元格值
|
||||
- `DownstreamReadback`: 若涉及看板 / 分组 / 排序 / lookup / 公式,继续读回结果
|
||||
- `CompletionRule`: 结构、值、下游能力都正确,才能回复“已完成”
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
批量更新记录(将同一份 `patch` 批量应用到一批 `record_id_list`)。
|
||||
通过 `update_records` 为每条记录提交字段值。
|
||||
|
||||
## 推荐命令
|
||||
|
||||
```bash
|
||||
lark-cli base +record-batch-update --base-token <base_token> --table-id <table_id> \
|
||||
--json '{"record_id_list":["<record_id>"],"patch":{"状态":"完成"}}'
|
||||
--json '{"update_records":{"<record_id_a>":{"状态":["完成"]},"<record_id_b>":{"分数":20}}}'
|
||||
|
||||
lark-cli base +record-batch-update --base-token <base_token> --table-id <table_id> --json @batch-update.json
|
||||
```
|
||||
@@ -29,23 +29,25 @@ lark-cli base +record-batch-update --base-token <base_token> --table-id <table_i
|
||||
|
||||
本节只说明 `+record-batch-update` 的外层 JSON 形状;CellValue 统一看 [lark-base-cell-value.md](lark-base-cell-value.md)。
|
||||
|
||||
对象形态:`{"record_id_list":[...],"patch":{...}}`。
|
||||
对象形态:
|
||||
|
||||
```json
|
||||
{"update_records":{"recA":{"状态":["完成"]},"recB":{"分数":20}}}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `record_id_list` | `string[]` | 是 | 要更新的记录 ID 列表(单次最多 200 条) |
|
||||
| `patch` | `Map<FieldNameOrID, CellValue>` | 是 | 字段更新对象;key 是字段名或字段 ID,value 是 `CellValue`;同一份 `patch` 会应用到 `record_id_list` 内所有记录 |
|
||||
| `update_records` | `Map<RecordID, Map<FieldNameOrID, CellValue>>` | 是 | record ID 到字段更新对象的映射(单次最多 200 条) |
|
||||
|
||||
## 返回重点
|
||||
|
||||
返回 `record_id_list`、`update`,可选返回 `ignored_fields`;`update` 可能为空对象。
|
||||
成功响应只包含可选的 `ignored_fields`;没有忽略字段时 `data` 为空对象。请求不会预先校验 record ID 是否存在,因此需要确认实际写入结果时,应再用 `+record-get` 读回目标记录。
|
||||
|
||||
## 坑点
|
||||
|
||||
- 这是“同值批量更新”:所有 `record_id_list` 都应用同一份 `patch`。
|
||||
- `record_id_list` 最大 200 条,超过会被接口校验拒绝。
|
||||
- 单次最多更新 200 条记录,超过会被接口校验拒绝。
|
||||
- 命令不会自动做字段/行映射转换,传什么就发什么。
|
||||
- 如果 `patch` 包含只读字段,返回里可能出现 `ignored_fields`;这些字段不会被更新。
|
||||
- 如果字段映射包含只读字段,返回里可能出现 `ignored_fields`;这些字段不会被更新。
|
||||
|
||||
## 参考
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
|
||||
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
|
||||
> 搜索用户接口不支持 bot 身份,需用 `--as user` 进行搜索。
|
||||
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
|
||||
|
||||
## 高级用法(完整 API 命令)
|
||||
|
||||
@@ -87,13 +87,21 @@ lark-cli docs +fetch --doc Z1Fj...tnAc \
|
||||
"document": {
|
||||
"document_id": "doxcnXXXX",
|
||||
"revision_id": 12,
|
||||
"content": "<title>标题</title><p>文档内容...</p>"
|
||||
"content": "<title>标题</title><p>文档内容...</p>",
|
||||
"reference_map": {
|
||||
"<block_type>": {
|
||||
"<ref>": {
|
||||
"<real-attr-key>": "<real-attr-value>"
|
||||
}
|
||||
}
|
||||
},
|
||||
"tips": "<safe replay or degradation guidance>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`content` 的格式由 `--doc-format` 决定;`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
|
||||
`content` 的格式由 `--doc-format` 决定。`reference_map` 是正文引用数据的结构化 sidecar:一级键 `block_type` 表示引用所在的块类型,二级键 `ref` 对应正文中的临时引用;每个引用的值是由 `real-attr-key` 和 `real-attr-value` 组成的真实属性映射,具体属性由块类型决定。没有提取数据时,`reference_map` 可能为空。`content` 和 `reference_map` 属于同一份响应,保留或回放内容时应配套处理。`tips` 给出安全回放或降级提示。`im-markdown` 仅用于获取内容后在 `lark-im` 场景下使用。设置 `--scope` 时会被 `<fragment>` 包裹,详见上文"局部读取的输出结构"。
|
||||
|
||||
## 参数
|
||||
|
||||
|
||||
@@ -125,9 +125,9 @@ Sub Agent 需要携带以下的最小上下文,以及后续的 [SVG 设计 Wor
|
||||
`../../lark-whiteboard/SKILL.md`](../../lark-whiteboard/SKILL.md) 编辑。
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +query \
|
||||
lark-cli whiteboard +export \
|
||||
--whiteboard-token "wbcnxxxxxxxx" \
|
||||
--output_as image \
|
||||
--output-type preview \
|
||||
--output ./preview.png
|
||||
```
|
||||
|
||||
|
||||
@@ -2,6 +2,47 @@
|
||||
|
||||
本文件用于补充说明 block XML 扩展能力。常用标签和通用规则见 [`lark-doc-xml.md`](lark-doc-xml.md);后续新增其他 block 说明时可继续追加到本文件。
|
||||
|
||||
## HTML5 block
|
||||
|
||||
1. 写入 HTML 内容块时,把完整单文件 HTML 存为本地 `.html` 文件,XML 写 `<html5-block path="@widget.html"></html5-block>`;已有 `data-ref` 时配合 `--reference-map @reference-map.json`。读取时 `<html5-block data-ref="html5_1"></html5-block>` 只是占位,必须从 `document.reference_map["html5-block"]["html5_1"].data` 读取 HTML;若 entry 是 `path`,读取对应 `@doc-fetch-resources/...html` 文件。
|
||||
2. 格式如下:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="use-iframe" content="true">
|
||||
<meta name="html-box-height-mode" content="auto">
|
||||
<meta name="description" content="内容摘要,会导出为 html5-block 的 alt 属性,帮助模型理解该 HTML 块的用途">
|
||||
<title></title>
|
||||
</head>
|
||||
<body>
|
||||
...
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### 布局与高度
|
||||
|
||||
- `lark-cli` 会读取 `.html` 文件并原样写入 `reference_map`,不会解析或校验 `html-box-height-mode`;创建或更新文档前在 `<head>` 中显式声明 `auto` 或 `viewport`。
|
||||
- 生成时只使用 `auto` 或 `viewport`,不要臆造 `fixed`、`initial` 或像素值等其他 mode。
|
||||
- 文档常见可用宽度约 `820px`;根容器使用 `width: 100%`、`max-width: 100%`、`box-sizing: border-box`。
|
||||
|
||||
四种策略:
|
||||
|
||||
1. 内容自然撑开:`auto` + 普通文档流;根容器不设固定高度或 `overflow: hidden`。
|
||||
2. 仅按初始内容定高:`auto` + 首次渲染后不再追加或展开内容。
|
||||
3. 固定像素操作区:`auto` + 业务容器按场景设置固定的 CSS `height` 和 `overflow: auto`;高度数值不写进 meta。
|
||||
4. 单屏应用:`viewport` + `100vh` + 内部滚动、切页或缩放;适用于游戏、幻灯片、Dashboard、canvas 编辑器。
|
||||
|
||||
正文需要在飞书文档中完整展开时选 `auto`;内容应在 HTML Block 内滚动时选 `viewport`。`lark-cli` 不参与页面加载后的高度刷新,不要臆造相关 CLI flag。
|
||||
|
||||
### 内容限制
|
||||
|
||||
- HTML 总长度上限为 500KB。不要内联大图片、Base64、字体、长 JSON/CSV 或大量 mock 数据。
|
||||
|
||||
## OKR block
|
||||
|
||||
OKR block 可用 XML 格式完整表达。创建前先参考 [`lark-okr`](../../lark-okr/SKILL.md) 确认可用周期;创建时只写 root-only `<okr cycle-id="..."/>` 挂载已有 OKR,不构造 Objective/KR/Progress 子树。
|
||||
|
||||
@@ -23,7 +23,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
## 行内组件
|
||||
| 标签 | 说明 | 关键属性 |
|
||||
|-|-|-|
|
||||
| `<cite type="user">` | @人 | `<cite type="user" user-id="userID"></cite>` |
|
||||
| `<cite type="user">` | @人 | XML 导入时必须显式传入 `user-id`:`<cite type="user" user-id="userID"></cite>` |
|
||||
| `<cite type="doc">` | @文档 | `<cite type="doc" doc-id="docx_token"></cite>` |
|
||||
| `<latex>` | 行内公式 | `<latex>E = mc^2</latex>` |
|
||||
| `<img>` | 图片(可独立成块或内联) | `<img width="800" height="600" caption="说明" name="图.png" href="http 或 https"/>` |
|
||||
@@ -46,8 +46,8 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
- `<task>` — `<task task-id="GUID"></task>`,必传 task-id(任务 guid)
|
||||
- `<chat_card>` — `<chat_card chat-id="CHAT_ID"></chat_card>`,必传 chat-id
|
||||
- `<sub-page-list>` — `<sub-page-list></sub-page-list>` 子页面列表块;仅 wiki 文档可插入
|
||||
- `<html5-block>`、`<okr>` — 前者在飞书文档「HTML 块」iframe 中加载单文件 HTML,内容可用 HTML 渲染时直接使用;后者创建时仅支持 root-only `<okr cycle-id="..."/>` 挂载已有 OKR。完整语法与字段规则见 [`lark-doc-xml-extended-blocks.md`](lark-doc-xml-extended-blocks.md)。
|
||||
- bitable、base_ref、synced_reference、synced_source — 不可创建,仅支持移动
|
||||
- `<okr>` — 创建时仅支持 root-only `<okr cycle-id="..."/>` 挂载已有 OKR;完整结构与字段规则见 [`lark-doc-xml-extended-blocks.md`](lark-doc-xml-extended-blocks.md#okr-block)
|
||||
|
||||
# 四、块级复制与移动
|
||||
|
||||
@@ -85,6 +85,7 @@ p, h1-h9, ul, ol, li, table, thead, tbody, tr, th, td, blockquote, pre, code, hr
|
||||
|
||||
## 用户名写入规则
|
||||
|
||||
- 任何包含 `<cite type="user">` 的 XML 在导入、新建或编辑回写时,都必须显式传入 `user-id`;其值为用户的 `open_id`,不得省略。
|
||||
- 当从 IM 消息、日历、审批、任务等来源获取到用户的 `open_id` 时,写入文档**必须**使用 `<cite type="user" user-id="open_id">` 标签,而非纯文本名字。这样文档中会渲染为可点击的 @人。
|
||||
- 典型场景:IM 消息的 `sender`、`mentions`、reactions 的 `operator`、卡片消息中引用的用户、系统消息中的用户名、合并转发中的用户名。
|
||||
- 当只有纯文本名字而没有 `open_id` 时(如系统消息、合并转发内容),先通过 `lark-cli contact +search-user --query "名字" --as user` 反查 `open_id`,再写入 cite 标签。
|
||||
|
||||
@@ -26,7 +26,10 @@ metadata:
|
||||
- 高风险写操作(删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要为指定飞书文档**设置 / 修改密级标签(secure label)**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
|
||||
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要“权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
|
||||
- 用户要**按特定主题、关键词或内容线索跨容器查找资料,并统一收集到 Drive 文件夹或 Wiki 节点**,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`topic_move_collector`](references/lark-drive-workflow-topic-move-collector.md) workflow。该 workflow 负责搜索召回、内容验证、相关性分类、移动计划、写前确认和结果验证;禁止直接从 `drive +search` 或 `drive +move` 开始。
|
||||
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案;创建目录、移动资源、申请权限都必须单独确认。
|
||||
- 按主题跨范围查找并集中归档,进入 `topic_move_collector`;对已知文件夹、文档库或知识库做目录盘点和结构重组,进入 `knowledge_organize`;只移动一个已明确资源时仍使用原子移动命令。
|
||||
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`,owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。
|
||||
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;支持妙搭 apps 的 `/page/<token>` URL;具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。
|
||||
- 妙搭 apps 评论场景:除新增全文/局部评论不支持外,评论列表、批量查询、解决/恢复、回复创建/读取/更新/删除、reaction 添加/删除等评论管理能力已支持;使用原生命令时文档类型传 `apps`(`file_type=apps`),裸 token 调 shortcut 时传 `--type apps`。
|
||||
@@ -135,7 +138,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`)
|
||||
| Shortcut | 说明 |
|
||||
|----------|----------|
|
||||
| [`+search`](references/lark-drive-search.md) | 搜索文档、Wiki、表格、文件夹等云空间对象;支持 `--edited-since`、`--created-by-me`、`--mine`、`--doc-types` 等扁平 flag;区分 original creator 与 owner 语义。 |
|
||||
| [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点。 |
|
||||
| [`+upload`](references/lark-drive-upload.md) | 上传本地文件到 Drive 文件夹或 wiki 节点;修改/重写/更新已有文件时优先覆盖上传,而不是直接上传一个新文件。 |
|
||||
| [`+create-folder`](references/lark-drive-create-folder.md) | 新建 Drive 文件夹,支持父文件夹与 bot 创建后自动授权。 |
|
||||
| [`+download`](references/lark-drive-download.md) | 下载 Drive 文件到本地。 |
|
||||
| [`+preview`](references/lark-drive-preview.md) | 查看或下载文件的 PDF / HTML / 文本 / 图片等预览产物。 |
|
||||
|
||||
@@ -190,9 +190,9 @@ lark-cli base +record-list --base-token '<base_token>' --table-id '<table_id>' -
|
||||
- 若要定位画板内部节点,切到 `lark-whiteboard` 读取 raw 节点结构:
|
||||
|
||||
```bash
|
||||
lark-cli whiteboard +query \
|
||||
lark-cli whiteboard +export \
|
||||
--whiteboard-token '<whiteboard_token>' \
|
||||
--output_as raw
|
||||
--output-type raw
|
||||
```
|
||||
|
||||
- 如果 raw 节点中存在唯一匹配 `quote` 的文本节点,可定位到该节点;如果有多个相同文本节点,仍然是弱匹配,需要结合位置、样式、用户描述或人工确认。
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
## 快速决策
|
||||
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../../lark-markdown/SKILL.md)。
|
||||
- 用户在修改/重写/更新已有普通文件时,优先使用覆盖上传方式,而不是直接上传一个新文件。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user