Compare commits

...

4 Commits

Author SHA1 Message Date
zhengkenghong
60e6bc2f3b feat(whiteboard): route node update through batch_update
Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-31 17:18:48 +08:00
zhengkenghong
1fd29e75a6 feat: add whiteboard node shortcuts
Add whiteboard node create, update, and delete shortcuts with focused dry-run and unit coverage. Document the new node-level operations in the embedded lark-whiteboard skill and split node shortcut unit tests by command for maintainability.

Co-authored-by: TRAE CLI <noreply@bytedance.com>
2026-07-31 17:18:45 +08:00
zhouyue-bytedance
5cf09ecfda docs(base): clarify form and file operation routing (#2110)
* docs(base): clarify form and file operation routing

* docs: clarify complete base role table rules

* docs: clarify base advanced permission status

* docs: clarify base form field lifecycle

* docs: guide base form question creation

* fix(base): address form dry-run review findings

* docs(base): add complete editable role example

* fix(base): validate form question create inputs
2026-07-31 15:23:03 +08:00
chenxingyang1019
41692b7041 feat(apps): add cache debug commands (+cache-get/-delete/-clear) (#1896)
* feat(apps): add cache debug commands (+cache-get/-delete/-clear)

Add three apps-domain cache debug shortcuts for inspecting/clearing an app's
runtime cache:
- +cache-get: read a business key's value + metadata (hit/miss)
- +cache-delete: delete a single key (idempotent, write)
- +cache-clear: clear all cache in an environment (high-risk-write, --yes)

value renders raw on --format json, deserialized on --format pretty;
value_size_bytes is computed CLI-side; --environment auto-selects the branch
when omitted. Includes unit tests (hit/miss/dry-run/confirmation) and the
lark-apps cache skill reference.

* fix(apps): normalize cache numeric output fields and tidy comments

Follow-up hardening for the cache debug commands (+cache-get/-delete/-clear):

- Normalize ttl_ms / deleted_key_count via a new cacheInt() helper so
  --format json emits a stable JSON number (or null) regardless of whether
  the server sends the value as a number or a string. Aligns with the
  repo convention that numeric wire fields may arrive as strings; previously
  these were passed through raw, leaving the output type at the server's mercy.
- Add unit tests locking the string-wire -> JSON number contract for both
  cache-get ttl_ms and cache-delete deleted_key_count.
- Tidy two comments: soften cacheBool's speculative "historical wire form"
  claim to a defensive-tolerance note, and drop implementation jargon from
  cache-delete's risk-level rationale.
2026-07-31 14:13:56 +08:00
43 changed files with 2788 additions and 47 deletions

View File

@@ -159,8 +159,7 @@ func cachedLarkClientFunc(f *Factory, workspaceConfig workspaceConfigSource) fun
Transport: sdkTransport,
CheckRedirect: safeRedirectPolicy,
}))
ep := core.ResolveEndpoints(acct.Brand)
opts = append(opts, lark.WithOpenBaseUrl(ep.Open))
opts = append(opts, lark.WithOpenBaseUrl(core.ResolveOpenBaseURL(acct.Brand)))
return lark.NewClient(acct.AppID, credential.RuntimeAppSecret(acct.AppSecret), opts...), nil
})
}

View File

@@ -6,6 +6,7 @@ package cmdutil
import (
"context"
"net/http"
"os"
"reflect"
"runtime/debug"
"strings"
@@ -201,14 +202,27 @@ func ShortcutHeaderOpts(ctx context.Context) larkcore.RequestOptionFunc {
// ShortcutHeaders extracts Shortcut info from the context and returns
// the corresponding HTTP headers. Returns nil if the context has no Shortcut info.
func ShortcutHeaders(ctx context.Context) http.Header {
name, ok := ShortcutNameFromContext(ctx)
if !ok {
return nil
}
h := make(http.Header)
h.Set(HeaderShortcut, name)
if eid, ok := ExecutionIdFromContext(ctx); ok {
h.Set(HeaderExecutionId, eid)
if name, ok := ShortcutNameFromContext(ctx); ok {
h.Set(HeaderShortcut, name)
if eid, ok := ExecutionIdFromContext(ctx); ok {
h.Set(HeaderExecutionId, eid)
}
}
if name, value := extraHeaderFromEnv(); name != "" && value != "" {
h.Set(name, value)
}
if len(h) == 0 {
return nil
}
return h
}
func extraHeaderFromEnv() (string, string) {
name := strings.TrimSpace(os.Getenv(envvars.CliExtraHeaderName))
value := strings.TrimSpace(os.Getenv(envvars.CliExtraHeaderValue))
if name == "" || value == "" {
return "", ""
}
return name, value
}

View File

@@ -3,7 +3,12 @@
package core
import "strings"
import (
"os"
"strings"
"github.com/larksuite/cli/internal/envvars"
)
// LarkBrand represents the Lark platform brand.
// "feishu" targets China-mainland, "lark" targets international.
@@ -61,5 +66,8 @@ func ResolveEndpoints(brand LarkBrand) Endpoints {
// ResolveOpenBaseURL returns the Open API base URL for the given brand.
func ResolveOpenBaseURL(brand LarkBrand) string {
if override := strings.TrimRight(strings.TrimSpace(os.Getenv(envvars.CliOpenBaseURL)), "/"); override != "" {
return override
}
return ResolveEndpoints(brand).Open
}

View File

@@ -58,6 +58,13 @@ func TestResolveOpenBaseURL(t *testing.T) {
}
}
func TestResolveOpenBaseURL_EnvOverride(t *testing.T) {
t.Setenv("LARKSUITE_CLI_OPEN_BASE_URL", "https://open.feishu-boe.cn/")
if got := ResolveOpenBaseURL(BrandFeishu); got != "https://open.feishu-boe.cn" {
t.Errorf("ResolveOpenBaseURL(feishu with env override) = %q", got)
}
}
func TestParseBrand(t *testing.T) {
cases := []struct {
in string

View File

@@ -22,6 +22,10 @@ const (
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliOpenBaseURL = "LARKSUITE_CLI_OPEN_BASE_URL"
CliExtraHeaderName = "LARKSUITE_CLI_EXTRA_HEADER_NAME"
CliExtraHeaderValue = "LARKSUITE_CLI_EXTRA_HEADER_VALUE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
CliProxyAddress = "LARKSUITE_CLI_PROXY_ADDRESS"
CliCAPath = "LARKSUITE_CLI_CA_PATH"

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsCacheClear clears all cache entries for the app in the given environment.
//
// POST /apps/{app_id}/cache/clearbody {env}。清空当前应用指定环境下全部缓存,用于无法定位
// 具体 key 的快速恢复;影响面大,定 high-risk-write框架自动注入 --yes 确认)。
var AppsCacheClear = common.Shortcut{
Service: appsService,
Command: "+cache-clear",
Description: "Clear all cache entries for the app in the given environment",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +cache-clear --app-id <app_id> --environment dev --yes",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
cacheEnvFlag(),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
POST(appCacheClearPath(appID)).
Desc("Clear all cache entries for the app in the given environment").
Body(dbEnvParams(rctx, map[string]interface{}{}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", appCacheClearPath(appID), nil, dbEnvParams(rctx, map[string]interface{}{}))
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := map[string]interface{}{
"environment": resolvedEnv(data, rctx),
"deleted_key_count": cacheInt(data["deleted_key_count"]),
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderCacheClearPretty(w, out)
})
return nil
},
}
// renderCacheClearPretty 打 "✓ cache cleared: N entries (env)"。
func renderCacheClearPretty(w io.Writer, out map[string]interface{}) {
n := int64(0)
if f, ok := numericAsFloat(out["deleted_key_count"]); ok {
n = int64(f)
}
fmt.Fprintf(w, "✓ cache cleared: %d entries (%s)\n", n, common.GetString(out, "environment"))
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsCacheDelete deletes a single business cache key (idempotent).
//
// DELETE /apps/{app_id}/cache?env=&key=。缓存是派生数据、删单 key 影响面小且可重建,
// 故定 write非 high-risk-write、不需 --yes。目标不存在按幂等成功处理deleted_key_count=0
var AppsCacheDelete = common.Shortcut{
Service: appsService,
Command: "+cache-delete",
Description: "Delete a single business cache key (idempotent)",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +cache-delete --app-id <app_id> --environment dev --key <key>",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "key", Desc: "business cache key", Required: true},
cacheEnvFlag(),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
DELETE(appCachePath(appID)).
Desc("Delete a Miaoda app runtime cache key").
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
key := rctx.Str("key")
data, err := rctx.CallAPITyped("DELETE", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := map[string]interface{}{
"key": key,
"environment": resolvedEnv(data, rctx),
"deleted_key_count": cacheInt(data["deleted_key_count"]),
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderCacheDeletePretty(w, out)
})
return nil
},
}
// renderCacheDeletePretty 命中打 "✓ cache deleted",幂等未命中打 "✓ cache already absent"(措辞区分,都成功)。
func renderCacheDeletePretty(w io.Writer, out map[string]interface{}) {
key := common.GetString(out, "key")
if n, ok := numericAsFloat(out["deleted_key_count"]); ok && n > 0 {
fmt.Fprintf(w, "✓ cache deleted: %s\n", key)
return
}
fmt.Fprintf(w, "✓ cache already absent: %s\n", key)
}

View File

@@ -0,0 +1,105 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsCacheGet reads a single business cache key's value + metadata.
//
// GET /apps/{app_id}/cache?env=&key=。value 在 wire 上是 JSON 字符串透传:--format json
// 原样输出该字符串(不反序列化),--format pretty 反序列化后缩进展开。value_size_bytes 由 CLI
// 按 value 字节长度算出端点不返回未命中exists=false时不带 valuettl_ms/value_size_bytes 为 null。
var AppsCacheGet = common.Shortcut{
Service: appsService,
Command: "+cache-get",
Description: "Get a business cache key's value and metadata",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +cache-get --app-id <app_id> --key spotbonus:2026:winners:list:v1",
"Example: lark-cli apps +cache-get --app-id <app_id> --environment online --key <key>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: "Miaoda app id", Required: true},
{Name: "key", Desc: "business cache key", Required: true},
cacheEnvFlag(),
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
_, err := requireAppID(rctx.Str("app-id"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
appID, _ := requireAppID(rctx.Str("app-id"))
return common.NewDryRunAPI().
GET(appCachePath(appID)).
Desc("Get a Miaoda app runtime cache key").
Params(dbEnvParams(rctx, map[string]interface{}{"key": rctx.Str("key")}))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
appID, err := requireAppID(rctx.Str("app-id"))
if err != nil {
return err
}
key := rctx.Str("key")
data, err := rctx.CallAPITyped("GET", appCachePath(appID), dbEnvParams(rctx, map[string]interface{}{"key": key}), nil)
if err != nil {
return withAppsHint(err, appIDListHint)
}
out := projectCacheGet(data, key, rctx)
rctx.OutFormat(out, nil, func(w io.Writer) {
renderCacheGetPretty(w, out)
})
return nil
},
}
// projectCacheGet 组装 cache-get 输出key 回显、environment 取 resolved env、exists 直读;
// 命中时带 ttl_ms + value原始串+ value_size_bytesCLI 算),未命中时 ttl_ms/value_size_bytes 为 null、无 value。
func projectCacheGet(data map[string]interface{}, key string, rctx *common.RuntimeContext) map[string]interface{} {
exists := cacheBool(data["exists"])
out := map[string]interface{}{
"key": key,
"environment": resolvedEnv(data, rctx),
"exists": exists,
}
if exists {
val := common.GetString(data, "value")
out["ttl_ms"] = cacheInt(data["ttl_ms"])
out["value_size_bytes"] = len([]byte(val))
out["value"] = val
} else {
out["ttl_ms"] = nil
out["value_size_bytes"] = nil
}
return out
}
// renderCacheGetPretty 打元信息块key/environment/exists命中再加 ttl/value_size命中时末尾展开 value。
func renderCacheGetPretty(w io.Writer, out map[string]interface{}) {
exists, _ := out["exists"].(bool)
pairs := [][2]string{
{"key", common.GetString(out, "key")},
{"environment", common.GetString(out, "environment")},
{"exists", fmt.Sprintf("%v", exists)},
}
if exists {
pairs = append(pairs,
[2]string{"ttl", formatCacheTTL(out["ttl_ms"])},
[2]string{"value_size", humanBytes(out["value_size_bytes"])},
)
}
renderKeyValuePairs(w, pairs)
if exists {
fmt.Fprintln(w, "value:")
printCacheValuePretty(w, common.GetString(out, "value"))
}
}

View File

@@ -0,0 +1,357 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
const (
cacheURL = "/open-apis/spark/v1/apps/app_x/cache"
cacheClearURL = "/open-apis/spark/v1/apps/app_x/cache/clear"
)
// cacheValueStr 是服务端在 wire 上透传的原始 JSON 字符串value 不反序列化)。
const cacheValueStr = `[{"name":"Alice","award":"Gold"},{"name":"Bob","award":"Silver"}]`
// ── cache-get ──
// TestAppsCacheGet_HitJSON命中时 json 默认——value 原样透传(不反序列化),
// value_size_bytes 由 CLI 按 value 字节长度算出environment 取服务端 resolved env。
func TestAppsCacheGet_HitJSON(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if d["key"] != "k:1" || d["environment"] != "online" || d["exists"] != true {
t.Fatalf("get hit data=%v", d)
}
if v, _ := d["value"].(string); v != cacheValueStr {
t.Fatalf("value must be raw passthrough string, got %v", d["value"])
}
if sz, _ := numericAsFloat(d["value_size_bytes"]); int(sz) != len(cacheValueStr) {
t.Fatalf("value_size_bytes = %v, want %d", d["value_size_bytes"], len(cacheValueStr))
}
// ttl_ms 必须是 JSON number透传服务端数字不得变成字符串JSON 解析后为 float64。
if _, ok := d["ttl_ms"].(float64); !ok {
t.Fatalf("ttl_ms must be a JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
}
}
// TestAppsCacheGet_HitPrettypretty 把 value 反序列化后展开(含缩进后的字段),并打元信息标签。
func TestAppsCacheGet_HitPretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": 272000, "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
got := stdout.String()
for _, want := range []string{"key", "environment", "exists", "value", "Alice"} {
if !strings.Contains(got, want) {
t.Errorf("pretty missing %q:\n%s", want, got)
}
}
}
// TestAppsCacheGet_Miss未命中——exists=false无 valuettl_ms / value_size_bytes 为 null。
func TestAppsCacheGet_Miss(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": false,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if d["exists"] != false {
t.Fatalf("miss exists=%v", d["exists"])
}
if _, ok := d["value"]; ok {
t.Fatalf("miss must not carry value: %v", d)
}
if d["ttl_ms"] != nil || d["value_size_bytes"] != nil {
t.Fatalf("miss ttl_ms/value_size_bytes must be null: %v", d)
}
}
// TestAppsCacheGet_ExistsAsString服务端把 exists 返成字符串 "true" 时仍按命中处理
// cacheBool 容错,防 exists 以字符串形态出现被误判成未命中、hit→miss 翻转)。
func TestAppsCacheGet_ExistsAsString(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": "true", "ttl_ms": 272000, "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if d["exists"] != true {
t.Fatalf("exists string \"true\" 应按命中解析, got exists=%v", d["exists"])
}
if v, _ := d["value"].(string); v != cacheValueStr {
t.Fatalf("命中应带 value, got %v", d["value"])
}
}
// TestAppsCacheGet_PrettyNonJSONFallbackpretty 下 value 不是合法 JSON 时降级原样输出
// safeParseJSON 解析失败→原样打印,不报错、不吞值)。补齐 HitPretty 只覆盖了"能反序列化"路径的缺口。
func TestAppsCacheGet_PrettyNonJSONFallback(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": 272000, "value": "hello-plain-not-json",
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "hello-plain-not-json") {
t.Fatalf("非 JSON value 应原样输出(降级), got:\n%s", stdout.String())
}
}
// TestAppsCacheGet_TTLAsStringNormalized服务端把 ttl_ms 返成字符串 "272000" 时,
// 输出的 ttl_ms 必须归一成 JSON numbercacheInt不得随 wire 形态漂移成字符串。
func TestAppsCacheGet_TTLAsStringNormalized(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{
"env": "online", "exists": true, "ttl_ms": "272000", "value": cacheValueStr,
}},
})
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "online", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
f, ok := d["ttl_ms"].(float64)
if !ok {
t.Fatalf("ttl_ms string wire 应归一成 JSON number, got %T (%v)", d["ttl_ms"], d["ttl_ms"])
}
if int(f) != 272000 {
t.Fatalf("ttl_ms = %v, want 272000", f)
}
}
// TestAppsCacheDelete_CountAsStringNormalized服务端把 deleted_key_count 返成字符串 "1" 时,
// 输出必须归一成 JSON numbercacheInt
func TestAppsCacheDelete_CountAsStringNormalized(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": "1"}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if _, ok := d["deleted_key_count"].(float64); !ok {
t.Fatalf("deleted_key_count string wire 应归一成 JSON number, got %T (%v)", d["deleted_key_count"], d["deleted_key_count"])
}
}
// TestAppsCacheGet_DryRunOmitsEnv不传 --environment 时 dry-run query 不带 env服务端自动选但带 key。
func TestAppsCacheGet_DryRunOmitsEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Method != "GET" || a.URL != cacheURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if _, ok := a.Params["env"]; ok {
t.Fatalf("no --environment → env must be omitted, params=%v", a.Params)
}
if a.Params["key"] != "k:1" {
t.Fatalf("key must be in query, params=%v", a.Params)
}
}
// TestAppsCacheGet_DryRunWithEnv显式 --environment dev → query 带 env=dev。
func TestAppsCacheGet_DryRunWithEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Params["env"] != "dev" {
t.Fatalf("env must be dev, params=%v", a.Params)
}
}
// TestAppsCacheGet_RequiresKey缺 --key → 校验错。
func TestAppsCacheGet_RequiresKey(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheGet,
[]string{"+cache-get", "--app-id", "app_x", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected required --key error")
}
}
// ── cache-delete ──
// TestAppsCacheDelete_Hit删中命中的 key → deleted_key_count=1pretty 打 "✓ cache deleted"。
func TestAppsCacheDelete_Hit(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 1}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "✓ cache deleted") {
t.Fatalf("pretty: %s", stdout.String())
}
}
// TestAppsCacheDelete_AbsentJSON目标不存在 → 幂等成功deleted_key_count=0pretty 措辞区分。
func TestAppsCacheDelete_AbsentJSON(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
d := parseEnvelopeData(t, stdout)
if sz, _ := numericAsFloat(d["deleted_key_count"]); int(sz) != 0 || d["key"] != "k:1" || d["environment"] != "dev" {
t.Fatalf("absent data=%v", d)
}
}
// TestAppsCacheDelete_AbsentPretty不存在 pretty 打 "✓ cache already absent"。
func TestAppsCacheDelete_AbsentPretty(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: cacheURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 0}},
})
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "already absent") {
t.Fatalf("pretty: %s", stdout.String())
}
}
// TestAppsCacheDelete_DryRunDELETE 方法、/cache 路由query 带 key + env。
func TestAppsCacheDelete_DryRun(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheDelete,
[]string{"+cache-delete", "--app-id", "app_x", "--environment", "dev", "--key", "k:1", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Method != "DELETE" || a.URL != cacheURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Params["key"] != "k:1" || a.Params["env"] != "dev" {
t.Fatalf("params=%v", a.Params)
}
}
// ── cache-clear ──
// TestAppsCacheClear_Success清空成功 → deleted_key_count=128pretty 打 "✓ cache cleared: 128 entries (dev)"。
func TestAppsCacheClear_Success(t *testing.T) {
factory, stdout, reg := newAppsExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST", URL: cacheClearURL,
Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"env": "dev", "deleted_key_count": 128}},
})
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--yes", "--format", "pretty", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("execute err=%v", err)
}
if !strings.Contains(stdout.String(), "✓ cache cleared: 128 entries (dev)") {
t.Fatalf("pretty: %s", stdout.String())
}
}
// TestAppsCacheClear_RequiresConfirmationhigh-risk-write 无 --yes → 被确认门拦截。
func TestAppsCacheClear_RequiresConfirmation(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout); err == nil {
t.Fatalf("expected confirmation gate without --yes")
}
}
// TestAppsCacheClear_DryRunBodyWithEnvdry-run POST /cache/clearbody 带 env=dev。
func TestAppsCacheClear_DryRunBodyWithEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--environment", "dev", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if a.Method != "POST" || a.URL != cacheClearURL {
t.Fatalf("dry-run = %s %s", a.Method, a.URL)
}
if a.Body["env"] != "dev" {
t.Fatalf("body must carry env=dev, body=%v", a.Body)
}
}
// TestAppsCacheClear_DryRunBodyOmitsEnv不传 --environment → body 不带 env服务端自动选
func TestAppsCacheClear_DryRunBodyOmitsEnv(t *testing.T) {
factory, stdout, _ := newAppsExecuteFactory(t)
if err := runAppsShortcut(t, AppsCacheClear,
[]string{"+cache-clear", "--app-id", "app_x", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
t.Fatalf("dry-run err=%v", err)
}
a := firstDryRunAPI(t, stdout.String())
if _, ok := a.Body["env"]; ok {
t.Fatalf("no --environment → body env must be omitted, body=%v", a.Body)
}
}
// firstDryRunAPI 解析 dry-run 输出的第一个 api[] 项method/url/params/body
// 复用本包规范的 dryRunAPIEnvelopeapi 现嵌在 data.api 下,见 dryrun_test.go
func firstDryRunAPI(t *testing.T, s string) dryRunAPICall {
t.Helper()
var env dryRunAPIEnvelope
if err := json.Unmarshal([]byte(s), &env); err != nil || len(env.API) == 0 {
t.Fatalf("bad dry-run json: %v\n%s", err, s)
}
return env.API[0]
}

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// 应用运行时缓存Cache调试命令共享件路由 + 环境 flag + 渲染。
//
// 三条命令都走 spark OpenAPI `/apps/{app_id}/cache[/clear]`按运行环境env→dbBranch隔离
// 环境 flag 用 cacheEnvFlag()(只 --environment不带 db 家族的旧名 --envenv 值经 dbEnv 读、
// 经 dbEnvParams 注入——get/delete 放 queryclear 放 body省略即服务端自动选分支
// appCachePath 返回缓存单 key 读/删 URLcacheGET 读、DELETE 删,靠方法区分)。
func appCachePath(appID string) string {
return fmt.Sprintf("%s/apps/%s/cache", apiBasePath, validate.EncodePathSegment(appID))
}
// appCacheClearPath 返回清空指定环境缓存 URLcache/clear。
func appCacheClearPath(appID string) string {
return fmt.Sprintf("%s/apps/%s/cache/clear", apiBasePath, validate.EncodePathSegment(appID))
}
// cacheEnvFlag 返回缓存命令的运行环境 flag。cache 是全新命令、从无旧名 --env
// 故只注册干净的 --environment不带 db 家族那套隐藏 --env + 拒收逻辑)。
// 省略即服务端按应用多环境状态自动选分支多环境→dev非多环境→online
func cacheEnvFlag() common.Flag {
return common.Flag{
Name: "environment",
Enum: []string{"dev", "online"},
Desc: "target runtime environment; leave unset to auto-select (multi-env app uses dev, single-env uses online), or pass dev/online",
}
}
// cacheBool 防御性解析布尔:真 bool 直接用;若服务端把 exists 返成字符串 "true"/"false" 也归一成 bool
// 其它类型按 false。避免 exists 万一以字符串形态出现时被误判成未命中hit→miss 翻转)。
func cacheBool(v interface{}) bool {
switch x := v.(type) {
case bool:
return x
case string:
return strings.EqualFold(strings.TrimSpace(x), "true")
}
return false
}
// cacheInt 把服务端下发的数值字段归一成 int64无法解析→nil。本仓惯例数值可能以字符串下发
// (见 numericAsFloat 的 string 分支),若直接透传,--format json 的字段类型会随服务端 wire 形态漂移
// number ↔ string。归一后输出类型恒定为数字或 null消费方无需自己容忍字符串。
func cacheInt(raw interface{}) interface{} {
if f, ok := numericAsFloat(raw); ok {
return int64(f)
}
return nil
}
// resolvedEnv 取服务端回吐的 resolved env缺失时兜底成请求侧 --environment可能为空
// 省略 --environment 时服务端自动选分支,靠服务端回吐才知道实际命中 dev / online。
func resolvedEnv(data map[string]interface{}, rctx *common.RuntimeContext) string {
if env := common.GetString(data, "env"); env != "" {
return env
}
return dbEnv(rctx)
}
// formatCacheTTL 把剩余 TTL毫秒格式化成 4m32s 这样的时长串;非数字返回 "—"。
func formatCacheTTL(ms interface{}) string {
f, ok := numericAsFloat(ms)
if !ok {
return "—"
}
return (time.Duration(int64(f)) * time.Millisecond).String()
}
// printCacheValuePretty 把 value 反序列化后缩进展开pretty 口径);非 JSON 则原样打印。
// 与「json 原样字符串、pretty 才反序列化」的设计一致。
func printCacheValuePretty(w io.Writer, raw string) {
v := safeParseJSON(raw)
if s, ok := v.(string); ok {
fmt.Fprintln(w, s)
return
}
b, err := json.MarshalIndent(v, "", " ")
if err != nil {
fmt.Fprintln(w, raw)
return
}
w.Write(b)
fmt.Fprintln(w)
}

View File

@@ -64,6 +64,9 @@ func Shortcuts() []common.Shortcut {
AppsFileUpload,
AppsFileDelete,
AppsFileQuotaGet,
AppsCacheGet,
AppsCacheDelete,
AppsCacheClear,
AppsGitCredentialInit,
AppsGitCredentialList,
AppsGitCredentialRemove,

View File

@@ -20,13 +20,14 @@ import (
// - 3 git-credential
// - 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset
// - 3 cacheget/delete/clear
// - 3 plugininstall/uninstall/list
// - 6 automationlist/get/create/update/enable/disable
// - 9 rolerole CRUD + role-member list/add/remove + role-match-list= 79
func TestAppsShortcuts_Returns79(t *testing.T) {
// - 9 rolerole CRUD + role-member list/add/remove + role-match-list= 82
func TestAppsShortcuts_Returns82(t *testing.T) {
got := Shortcuts()
if len(got) != 79 {
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
if len(got) != 82 {
t.Fatalf("Shortcuts() returned %d entries, want 82", len(got))
}
}

View File

@@ -8,6 +8,7 @@ import (
"encoding/json"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
@@ -27,19 +28,23 @@ var BaseFormQuestionsCreate = common.Shortcut{
{Name: "form-id", Desc: "form ID", Required: true},
{Name: "questions", Desc: `questions JSON array, max 10 items. Each item requires "title"(field title) and "type"(text/number/select/datetime/user/attachment/location). Optional fields: "description"(plain text or markdown link like [text](https://example.com)),"required","option_display_mode"(0=dropdown/1=vertical/2=horizontal,select only),"multiple"(bool,select/user),"options"([{"name":"opt","hue":"Blue"}],select only),"style"({"type":"plain/phone/url/email/barcode/rating","precision":2,"format":"yyyy/MM/dd","icon":"star","min":1,"max":5}),"visible_rule"(display condition; same shape as view filter {"logic":"and","conditions":[["前序题目","==","是"]]}, field references another question's title/id, empty/absent = always shown). E.g. '[{"type":"text","title":"Your name","required":true}]'`, Required: true},
},
Tips: []string{
"If the form may already contain questions and has not been checked, run +form-questions-list for the same --base-token, --table-id, and --form-id. A verified empty form can create directly.",
"Each new question creates a field in the form's table; question IDs are field IDs.",
"Unless the user explicitly requests a separate same-title question, update an existing title with +form-questions-update instead of creating a duplicate.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := parseFormQuestionsCreate(runtime.Str("questions"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
api := common.NewDryRunAPI().
questions, _ := parseFormQuestionsCreate(runtime.Str("questions"))
return common.NewDryRunAPI().
POST("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/questions").
Set("base_token", runtime.Str("base-token")).
Set("table_id", runtime.Str("table-id")).
Set("form_id", runtime.Str("form-id"))
// Transcribe the questions body verbatim so the preview shows exactly
// what would be sent (including optional fields like visible_rule).
var questions []interface{}
if err := json.Unmarshal([]byte(runtime.Str("questions")), &questions); err == nil {
api.Body(map[string]interface{}{"questions": questions})
}
return api
Set("form_id", runtime.Str("form-id")).
Body(map[string]interface{}{"questions": questions})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
baseToken := runtime.Str("base-token")
@@ -47,9 +52,9 @@ var BaseFormQuestionsCreate = common.Shortcut{
formId := runtime.Str("form-id")
questionsJSON := runtime.Str("questions")
var questions []interface{}
if err := json.Unmarshal([]byte(questionsJSON), &questions); err != nil {
return baseValidationErrorf("--questions must be a valid JSON array: %s", err)
questions, err := parseFormQuestionsCreate(questionsJSON)
if err != nil {
return err
}
data, err := baseV3Call(runtime, "POST",
@@ -78,3 +83,31 @@ var BaseFormQuestionsCreate = common.Shortcut{
return nil
},
}
func parseFormQuestionsCreate(raw string) ([]interface{}, error) {
var questions []interface{}
if err := json.Unmarshal([]byte(raw), &questions); err != nil {
return nil, baseValidationErrorf("--questions must be a valid JSON array: %s", err)
}
if questions == nil {
return nil, baseValidationErrorf("--questions must be a non-null JSON array")
}
if len(questions) > 10 {
return nil, baseValidationErrorf("--questions must contain at most 10 items")
}
for i, question := range questions {
item, ok := question.(map[string]interface{})
if !ok {
return nil, baseValidationErrorf("--questions item %d must be an object", i+1)
}
title, ok := item["title"].(string)
if !ok || strings.TrimSpace(title) == "" {
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"title\"", i+1)
}
questionType, ok := item["type"].(string)
if !ok || strings.TrimSpace(questionType) == "" {
return nil, baseValidationErrorf("--questions item %d must include a non-empty string \"type\"", i+1)
}
}
return questions, nil
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"strings"
"testing"
)
func TestBaseFormQuestionsCreateTipsRequireExistingQuestionCheck(t *testing.T) {
tips := strings.Join(BaseFormQuestionsCreate.Tips, "\n")
for _, want := range []string{
"+form-questions-list",
"verified empty form can create directly",
"question IDs are field IDs",
"explicitly requests a separate same-title question",
"+form-questions-update",
} {
if !strings.Contains(tips, want) {
t.Fatalf("tips missing %q:\n%s", want, tips)
}
}
}

View File

@@ -98,6 +98,26 @@ func TestCallAPITyped_Success(t *testing.T) {
}
}
func TestCallAPITyped_ExtraHeaderFromEnv(t *testing.T) {
t.Setenv("LARKSUITE_CLI_EXTRA_HEADER_NAME", "x-tt-env")
t.Setenv("LARKSUITE_CLI_EXTRA_HEADER_VALUE", "boe_whiteboard_test")
rt, reg := newCallAPITypedRuntime(t)
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/board/v1/whiteboards/wb/nodes/batch_update",
Body: map[string]interface{}{"code": float64(0), "data": map[string]interface{}{"ids": []interface{}{"a1:1"}}},
}
reg.Register(stub)
_, err := rt.CallAPITyped("PUT", "/open-apis/board/v1/whiteboards/wb/nodes/batch_update", nil, map[string]any{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got := stub.CapturedHeaders.Get("x-tt-env"); got != "boe_whiteboard_test" {
t.Fatalf("x-tt-env header = %q, want boe_whiteboard_test", got)
}
}
// TestAPIClassifyContext verifies the classify context is built from the
// runtime: Brand / AppID from config, Identity from the resolved caller, and
// LarkCmd from the running command path.

View File

@@ -14,6 +14,9 @@ func Shortcuts() []common.Shortcut {
WhiteboardUpdateOld,
WhiteboardExport,
WhiteboardQuery,
WhiteboardNodeCreate,
WhiteboardNodeUpdate,
WhiteboardNodeDelete,
}
}

View File

@@ -0,0 +1,145 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/client"
"github.com/larksuite/cli/shortcuts/common"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
)
type whiteboardNodeBatchPayload struct {
Nodes []map[string]interface{} `json:"nodes"`
}
func parseWhiteboardNodeBatchPayload(raw []byte, requireID bool) (whiteboardNodeBatchPayload, error) {
var payload whiteboardNodeBatchPayload
if err := json.Unmarshal(raw, &payload); err != nil {
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unmarshal input json failed: %v", err).
WithParam("--source").
WithCause(err)
}
if len(payload.Nodes) == 0 {
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, `--source must include non-empty "nodes"`).
WithParam("--source")
}
if requireID {
for i, node := range payload.Nodes {
id, ok := node["id"].(string)
if !ok || strings.TrimSpace(id) == "" {
return whiteboardNodeBatchPayload{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "nodes[%d].id must be a non-empty string", i).
WithParam("--source")
}
}
}
return payload, nil
}
func parseWhiteboardNodeIDs(raw string) ([]string, error) {
if strings.TrimSpace(raw) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-ids is required").
WithParam("--node-ids")
}
parts := strings.Split(raw, ",")
ids := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for i, part := range parts {
id := strings.TrimSpace(part)
if id == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-ids item %d must not be empty", i+1).
WithParam("--node-ids")
}
if _, ok := seen[id]; ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "duplicate node id %q", id).
WithParam("--node-ids")
}
seen[id] = struct{}{}
ids = append(ids, id)
}
return ids, nil
}
func validateOptionalWhiteboardNodeIdempotentToken(raw string) error {
if err := common.RejectDangerousCharsTyped("--idempotent-token", raw); err != nil {
return err
}
if raw != "" && len(raw) < 10 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--idempotent-token must be at least 10 characters long.").
WithParam("--idempotent-token")
}
return nil
}
func callWhiteboardNodeWrite(ctx context.Context, runtime *common.RuntimeContext, method, apiPath string, params map[string]interface{}, body interface{}) (map[string]interface{}, error) {
req := &larkcore.ApiReq{
HttpMethod: method,
ApiPath: apiPath,
Body: body,
QueryParams: whiteboardNodeQueryParams(params),
}
resp, err := runtime.DoAPI(req)
if err != nil {
return nil, err
}
data, classifyErr := runtime.ClassifyAPIResponse(resp)
if classifyErr == nil {
return data, nil
}
if resp.StatusCode >= http.StatusBadRequest {
return data, classifyErr
}
if isWhiteboardNodeNonObjectSuccess(classifyErr, resp) {
return nil, nil
}
return data, classifyErr
}
func whiteboardNodeQueryParams(params map[string]interface{}) larkcore.QueryParams {
query := make(larkcore.QueryParams)
for key, value := range params {
switch typed := value.(type) {
case []string:
for _, item := range typed {
query.Add(key, item)
}
case []interface{}:
for _, item := range typed {
query.Add(key, whiteboardNodeQueryValue(item))
}
default:
query.Set(key, whiteboardNodeQueryValue(value))
}
}
return query
}
func whiteboardNodeQueryValue(value interface{}) string {
if value == nil {
return ""
}
return strings.TrimSpace(fmt.Sprint(value))
}
func isWhiteboardNodeNonObjectSuccess(err error, resp *larkcore.ApiResp) bool {
if resp == nil {
return false
}
if _, ok := errs.ProblemOf(err); !ok {
return false
}
result, parseErr := client.ParseJSONResponse(resp)
if parseErr != nil {
return false
}
_, isObject := result.(map[string]interface{})
return !isObject
}

View File

@@ -0,0 +1,150 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"testing"
)
func TestShortcutsIncludesWhiteboardNodeCommands(t *testing.T) {
t.Parallel()
got := Shortcuts()
want := []string{
"+update",
"+export",
"+query",
"+node-create",
"+node-update",
"+node-delete",
}
seen := make(map[string]bool, len(got))
for _, shortcut := range got {
if seen[shortcut.Command] {
t.Fatalf("duplicate shortcut command: %s", shortcut.Command)
}
seen[shortcut.Command] = true
}
for _, command := range want {
if !seen[command] {
t.Fatalf("missing shortcut command %q in Shortcuts()", command)
}
}
}
func TestParseWhiteboardNodeBatchPayload_MissingNodes(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`{}`), false)
assertValidationParam(t, err, "--source", false)
}
func TestParseWhiteboardNodeBatchPayload_EmptyNodes(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[]}`), false)
assertValidationParam(t, err, "--source", false)
}
func TestParseWhiteboardNodeBatchPayload_InvalidJSONPreservesCause(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`not-json`), false)
assertValidationParam(t, err, "--source", true)
}
func TestParseWhiteboardNodeBatchPayload_RequireIDMissingID(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"text":{"text":"x"}}]}`), true)
assertValidationParam(t, err, "--source", false)
}
func TestParseWhiteboardNodeBatchPayload_RequireIDBlankID(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"id":" ","text":{"text":"x"}}]}`), true)
assertValidationParam(t, err, "--source", false)
}
func TestParseWhiteboardNodeBatchPayload_PreservesArbitraryFields(t *testing.T) {
t.Parallel()
payload, err := parseWhiteboardNodeBatchPayload([]byte(`{"nodes":[{"id":"node-1","type":"shape","custom":{"x":1},"points":[1,2]}]}`), true)
if err != nil {
t.Fatalf("parseWhiteboardNodeBatchPayload() error = %v", err)
}
if len(payload.Nodes) != 1 {
t.Fatalf("len(payload.Nodes) = %d, want 1", len(payload.Nodes))
}
node := payload.Nodes[0]
if got := node["id"]; got != "node-1" {
t.Errorf("node[id] = %v, want node-1", got)
}
if got := node["type"]; got != "shape" {
t.Errorf("node[type] = %v, want shape", got)
}
custom, ok := node["custom"].(map[string]interface{})
if !ok {
t.Fatalf("node[custom] = %T, want map[string]interface{}", node["custom"])
}
if got := custom["x"]; got != float64(1) {
t.Errorf("node[custom][x] = %v, want 1", got)
}
points, ok := node["points"].([]interface{})
if !ok {
t.Fatalf("node[points] = %T, want []interface{}", node["points"])
}
if len(points) != 2 || points[0] != float64(1) || points[1] != float64(2) {
t.Errorf("node[points] = %#v, want [1 2]", points)
}
}
func TestParseWhiteboardNodeIDs_TrimsItems(t *testing.T) {
t.Parallel()
ids, err := parseWhiteboardNodeIDs(" nodeA, nodeB ,nodeC ")
if err != nil {
t.Fatalf("parseWhiteboardNodeIDs() error = %v", err)
}
want := []string{"nodeA", "nodeB", "nodeC"}
if len(ids) != len(want) {
t.Fatalf("len(ids) = %d, want %d", len(ids), len(want))
}
for i := range want {
if ids[i] != want[i] {
t.Errorf("ids[%d] = %q, want %q", i, ids[i], want[i])
}
}
}
func TestParseWhiteboardNodeIDs_RejectsEmptyInput(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeIDs(" ")
assertValidationParam(t, err, "--node-ids", false)
}
func TestParseWhiteboardNodeIDs_RejectsEmptyItems(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeIDs("nodeA, ,nodeB")
assertValidationParam(t, err, "--node-ids", false)
}
func TestParseWhiteboardNodeIDs_RejectsDuplicateIDs(t *testing.T) {
t.Parallel()
_, err := parseWhiteboardNodeIDs("nodeA,nodeB,nodeA")
assertValidationParam(t, err, "--node-ids", false)
}
func TestValidateOptionalWhiteboardNodeIdempotentToken_TooShort(t *testing.T) {
t.Parallel()
err := validateOptionalWhiteboardNodeIdempotentToken("short")
assertValidationParam(t, err, "--idempotent-token", false)
}

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var wbNodeCreateScopes = []string{"board:whiteboard:node:create"}
var wbNodeCreateAuthTypes = []string{"user", "bot"}
var wbNodeCreateFlags = []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to create nodes in. You need edit permission on the whiteboard.", Required: true},
{Name: "source", Desc: `JSON payload containing a non-empty "nodes" array.`, Required: true, Input: []string{common.Stdin, common.File}},
{Name: "idempotent-token", Desc: "idempotent token to make create requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
}
type whiteboardNodeCreateReq struct {
Nodes []map[string]interface{} `json:"nodes"`
}
func wbNodeCreateValidate(_ context.Context, runtime *common.RuntimeContext) error {
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
return err
}
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
return err
}
_, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
return err
}
func wbNodeCreateDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
if err != nil {
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
}
dry := common.NewDryRunAPI().
POST(wbNodeCreateDryRunURL(runtime.Str("whiteboard-token"))).
Body(whiteboardNodeCreateReq{Nodes: payload.Nodes}).
Desc("create nodes in the whiteboard.")
if params := wbNodeCreateParams(runtime); len(params) > 0 {
dry.Params(params)
}
return dry
}
func wbNodeCreateExecute(_ context.Context, runtime *common.RuntimeContext) error {
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), false)
if err != nil {
return err
}
data, err := runtime.CallAPITyped(
http.MethodPost,
wbNodeCreateURL(runtime.Str("whiteboard-token")),
wbNodeCreateParams(runtime),
whiteboardNodeCreateReq{Nodes: payload.Nodes},
)
if err != nil {
return err
}
nodeIDs, err := whiteboardNodeCreateIDs(data)
if err != nil {
return err
}
outData := map[string]string{}
if nodeIDs != nil {
outData["ids"] = strings.Join(nodeIDs, ",")
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
if outData["ids"] != "" {
fmt.Fprintf(w, "%d new nodes created.\n", len(nodeIDs))
}
fmt.Fprintf(w, "Create whiteboard nodes success")
})
return nil
}
func wbNodeCreateURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", url.PathEscape(token))
}
func wbNodeCreateDryRunURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes", common.MaskToken(url.PathEscape(token)))
}
func wbNodeCreateParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
if token := runtime.Str("idempotent-token"); token != "" {
params["client_token"] = token
}
return params
}
func whiteboardNodeCreateIDs(data map[string]interface{}) ([]string, error) {
switch raw := data["ids"].(type) {
case nil:
return nil, nil
case []interface{}:
out := make([]string, 0, len(raw))
for i, value := range raw {
id, ok := value.(string)
if !ok {
return nil, wbInvalidResponse("create whiteboard nodes failed: data.ids[%d] must be a string", i)
}
out = append(out, id)
}
return out, nil
case []string:
return append([]string(nil), raw...), nil
default:
return nil, wbInvalidResponse("create whiteboard nodes failed: data.ids must be an array of strings")
}
}
// WhiteboardNodeCreate registers the `whiteboard +node-create` shortcut.
var WhiteboardNodeCreate = common.Shortcut{
Service: "whiteboard",
Command: "+node-create",
Description: "Create nodes in an existing whiteboard.",
Risk: "write",
Scopes: wbNodeCreateScopes,
AuthTypes: wbNodeCreateAuthTypes,
Flags: wbNodeCreateFlags,
Validate: wbNodeCreateValidate,
DryRun: wbNodeCreateDryRun,
Execute: wbNodeCreateExecute,
}

View File

@@ -0,0 +1,173 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func TestWhiteboardNodeCreateValidate_InvalidSourceTypedParam(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"source": "not-json",
}, nil)
err := wbNodeCreateValidate(context.Background(), rt)
assertValidationParam(t, err, "--source", true)
}
func TestWhiteboardNodeCreateDryRun_RequestShape(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"idempotent-token": "create-token-12345",
"source": `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`,
}, nil)
dryRun := wbNodeCreateDryRun(context.Background(), rt)
if dryRun == nil {
t.Fatal("wbNodeCreateDryRun() returned nil")
}
var got 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"`
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
}
if len(got.API) != 1 {
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
}
if got.API[0].Method != "POST" {
t.Fatalf("method = %q, want POST", got.API[0].Method)
}
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes" {
t.Fatalf("url = %q, want node-create URL", got.API[0].URL)
}
if got.API[0].Params["client_token"] != "create-token-12345" {
t.Fatalf("params.client_token = %#v, want create-token-12345", got.API[0].Params["client_token"])
}
nodes, ok := got.API[0].Body["nodes"].([]interface{})
if !ok || len(nodes) != 1 {
t.Fatalf("body.nodes = %#v, want one node", got.API[0].Body["nodes"])
}
node, ok := nodes[0].(map[string]interface{})
if !ok || node["type"] != "composite_shape" {
t.Fatalf("body.nodes[0] = %#v, want type composite_shape", nodes[0])
}
if _, ok := node["composite_shape"].(map[string]interface{}); !ok {
t.Fatalf("body.nodes[0].composite_shape = %#v, want object", node["composite_shape"])
}
}
func TestWhiteboardNodeCreateExecute_PostsNodes(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"ids": []string{"node-1"},
},
},
}
reg.Register(stub)
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
if err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
}
nodes, ok := body["nodes"].([]interface{})
if !ok || len(nodes) != 1 {
t.Fatalf("body.nodes = %#v, want one node; body=%s", body["nodes"], string(stub.CapturedBody))
}
node, ok := nodes[0].(map[string]interface{})
if !ok || node["type"] != "composite_shape" {
t.Fatalf("body.nodes[0] = %#v, want type composite_shape", nodes[0])
}
if _, ok := node["composite_shape"].(map[string]interface{}); !ok {
t.Fatalf("body.nodes[0].composite_shape = %#v, want object", node["composite_shape"])
}
if !strings.Contains(stdout.String(), `"ids": "node-1"`) {
t.Fatalf("stdout=%s, want ids node-1", stdout.String())
}
}
func TestWhiteboardNodeCreateExecute_AllowsMissingIDs(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
if err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
}
func TestWhiteboardNodeCreateExecute_RejectsMalformedIDs(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"ids": []interface{}{"node-1", 2},
},
},
})
source := `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`
args := []string{"+node-create", "--whiteboard-token", "test-board", "--source", source}
err := runUpdateShortcut(t, WhiteboardNodeCreate, args, factory, stdout)
if err == nil {
t.Fatal("expected malformed ids error, got nil")
}
var internalErr *errs.InternalError
if !errors.As(err, &internalErr) {
t.Fatalf("error type = %T, want *errs.InternalError", err)
}
if internalErr.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("Subtype = %q, want %q", internalErr.Subtype, errs.SubtypeInvalidResponse)
}
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var wbNodeDeleteScopes = []string{"board:whiteboard:node:delete"}
var wbNodeDeleteAuthTypes = []string{"user", "bot"}
var wbNodeDeleteFlags = []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to delete nodes from. You need edit permission on the whiteboard.", Required: true},
{Name: "node-ids", Desc: "comma-separated whiteboard node IDs to delete.", Required: true},
{Name: "idempotent-token", Desc: "idempotent token to make delete requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
}
type whiteboardNodeDeleteReq struct {
IDs []string `json:"ids"`
}
func wbNodeDeleteValidate(_ context.Context, runtime *common.RuntimeContext) error {
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
return err
}
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
return err
}
_, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
return err
}
func wbNodeDeleteDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ids, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
if err != nil {
return common.NewDryRunAPI().Desc("parse node ids failed: " + err.Error())
}
dry := common.NewDryRunAPI().
DELETE(wbNodeDeleteDryRunURL(runtime.Str("whiteboard-token"))).
Body(whiteboardNodeDeleteReq{IDs: ids}).
Desc("delete nodes from the whiteboard.")
if params := wbNodeDeleteParams(runtime); len(params) > 0 {
dry.Params(params)
}
return dry
}
func wbNodeDeleteExecute(ctx context.Context, runtime *common.RuntimeContext) error {
ids, err := parseWhiteboardNodeIDs(runtime.Str("node-ids"))
if err != nil {
return err
}
if _, err := callWhiteboardNodeWrite(
ctx,
runtime,
http.MethodDelete,
wbNodeDeleteURL(runtime.Str("whiteboard-token")),
wbNodeDeleteParams(runtime),
whiteboardNodeDeleteReq{IDs: ids},
); err != nil {
return err
}
outData := map[string]interface{}{
"ids": strings.Join(ids, ","),
"count": len(ids),
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "%d nodes deleted.\n", len(ids))
fmt.Fprintf(w, "Delete whiteboard nodes success")
})
return nil
}
func wbNodeDeleteURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", url.PathEscape(token))
}
func wbNodeDeleteDryRunURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_delete", common.MaskToken(url.PathEscape(token)))
}
func wbNodeDeleteParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
if token := runtime.Str("idempotent-token"); token != "" {
params["client_token"] = token
}
return params
}
// WhiteboardNodeDelete registers the `whiteboard +node-delete` shortcut.
var WhiteboardNodeDelete = common.Shortcut{
Service: "whiteboard",
Command: "+node-delete",
Description: "Delete nodes from an existing whiteboard.",
Risk: "high-risk-write",
Scopes: wbNodeDeleteScopes,
AuthTypes: wbNodeDeleteAuthTypes,
Flags: wbNodeDeleteFlags,
Validate: wbNodeDeleteValidate,
DryRun: wbNodeDeleteDryRun,
Execute: wbNodeDeleteExecute,
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestWhiteboardNodeDeleteValidate_InvalidNodeIDsTypedParam(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"node-ids": "nodeA,,nodeB",
}, nil)
err := wbNodeDeleteValidate(context.Background(), rt)
assertValidationParam(t, err, "--node-ids", false)
}
func TestWhiteboardNodeDeleteMetadata_RiskHighRiskWrite(t *testing.T) {
t.Parallel()
if WhiteboardNodeDelete.Risk != "high-risk-write" {
t.Fatalf("Risk = %q, want high-risk-write", WhiteboardNodeDelete.Risk)
}
}
func TestWhiteboardNodeDeleteDryRun_RequestShape(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"node-ids": "nodeA,nodeB",
"idempotent-token": "delete-token-12345",
}, nil)
dryRun := wbNodeDeleteDryRun(context.Background(), rt)
if dryRun == nil {
t.Fatal("wbNodeDeleteDryRun() returned nil")
}
var got 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"`
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
}
if len(got.API) != 1 {
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
}
if got.API[0].Method != "DELETE" {
t.Fatalf("method = %q, want DELETE", got.API[0].Method)
}
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes/batch_delete" {
t.Fatalf("url = %q, want masked node-delete URL", got.API[0].URL)
}
if got.API[0].Params["client_token"] != "delete-token-12345" {
t.Fatalf("params.client_token = %#v, want delete-token-12345", got.API[0].Params["client_token"])
}
ids, ok := got.API[0].Body["ids"].([]interface{})
if !ok || len(ids) != 2 {
t.Fatalf("body.ids = %#v, want two ids", got.API[0].Body["ids"])
}
if ids[0] != "nodeA" || ids[1] != "nodeB" {
t.Fatalf("body.ids = %#v, want [nodeA nodeB]", ids)
}
}
func TestWhiteboardNodeDeleteExecute_PostsIDs(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
stub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_delete",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
}
reg.Register(stub)
args := []string{"+node-delete", "--whiteboard-token", "test-board", "--node-ids", "nodeA,nodeB"}
if err := runUpdateShortcut(t, WhiteboardNodeDelete, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
}
ids, ok := body["ids"].([]interface{})
if !ok || len(ids) != 2 {
t.Fatalf("body.ids = %#v, want two ids; body=%s", body["ids"], string(stub.CapturedBody))
}
if ids[0] != "nodeA" || ids[1] != "nodeB" {
t.Fatalf("body.ids = %#v, want [nodeA nodeB]", ids)
}
if !strings.Contains(stdout.String(), `"ids": "nodeA,nodeB"`) {
t.Fatalf("stdout=%s, want ids nodeA,nodeB", stdout.String())
}
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"github.com/larksuite/cli/shortcuts/common"
)
var wbNodeUpdateScopes = []string{"board:whiteboard:node:update"}
var wbNodeUpdateAuthTypes = []string{"user", "bot"}
var wbNodeUpdateFlags = []common.Flag{
{Name: "whiteboard-token", Desc: "whiteboard token of the whiteboard to update nodes in. You need edit permission on the whiteboard.", Required: true},
{Name: "source", Desc: `JSON payload containing a non-empty "nodes" array. Each node must include "id"; the batch_update body sends the full nodes array.`, Required: true, Input: []string{common.Stdin, common.File}},
{Name: "idempotent-token", Desc: "idempotent token to make batch update requests retry-safe. Default is empty. Minimum length is 10.", Required: false},
}
func wbNodeUpdateValidate(_ context.Context, runtime *common.RuntimeContext) error {
if err := common.RejectDangerousCharsTyped("--whiteboard-token", runtime.Str("whiteboard-token")); err != nil {
return err
}
if err := validateOptionalWhiteboardNodeIdempotentToken(runtime.Str("idempotent-token")); err != nil {
return err
}
_, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
return err
}
func wbNodeUpdateDryRun(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
if err != nil {
return common.NewDryRunAPI().Desc("parse input failed: " + err.Error())
}
dry := common.NewDryRunAPI().
PUT(wbNodeBatchUpdateDryRunURL(runtime.Str("whiteboard-token"))).
Body(whiteboardNodeBatchUpdateBody(payload)).
Desc("batch update nodes in the whiteboard.")
if params := wbNodeUpdateParams(runtime); len(params) > 0 {
dry.Params(params)
}
return dry
}
func wbNodeUpdateExecute(ctx context.Context, runtime *common.RuntimeContext) error {
payload, err := parseWhiteboardNodeBatchPayload([]byte(runtime.Str("source")), true)
if err != nil {
return err
}
data, err := runtime.CallAPITyped(
http.MethodPut,
wbNodeBatchUpdateURL(runtime.Str("whiteboard-token")),
wbNodeUpdateParams(runtime),
whiteboardNodeBatchUpdateBody(payload),
)
if err != nil {
return err
}
updatedNodeIDs, err := whiteboardNodeUpdateIDs(data)
if err != nil {
return err
}
outData := map[string]interface{}{
"ids": strings.Join(updatedNodeIDs, ","),
"count": len(updatedNodeIDs),
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "%d nodes updated.\n", len(updatedNodeIDs))
fmt.Fprintf(w, "Update whiteboard nodes success")
})
return nil
}
func wbNodeBatchUpdateURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_update", url.PathEscape(token))
}
func wbNodeBatchUpdateDryRunURL(token string) string {
return fmt.Sprintf("/open-apis/board/v1/whiteboards/%s/nodes/batch_update", common.MaskToken(url.PathEscape(token)))
}
func wbNodeUpdateParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
if token := runtime.Str("idempotent-token"); token != "" {
params["client_token"] = token
}
return params
}
func whiteboardNodeBatchUpdateBody(payload whiteboardNodeBatchPayload) map[string]interface{} {
return map[string]interface{}{"nodes": payload.Nodes}
}
func whiteboardNodeUpdateIDs(data map[string]interface{}) ([]string, error) {
switch raw := data["ids"].(type) {
case nil:
return nil, nil
case []interface{}:
out := make([]string, 0, len(raw))
for i, value := range raw {
id, ok := value.(string)
if !ok {
return nil, wbInvalidResponse("update whiteboard nodes failed: data.ids[%d] must be a string", i)
}
out = append(out, id)
}
return out, nil
case []string:
return append([]string(nil), raw...), nil
default:
return nil, wbInvalidResponse("update whiteboard nodes failed: data.ids must be an array of strings")
}
}
// WhiteboardNodeUpdate registers the `whiteboard +node-update` shortcut.
var WhiteboardNodeUpdate = common.Shortcut{
Service: "whiteboard",
Command: "+node-update",
Description: "Update nodes in an existing whiteboard.",
Risk: "write",
Scopes: wbNodeUpdateScopes,
AuthTypes: wbNodeUpdateAuthTypes,
Flags: wbNodeUpdateFlags,
Tips: []string{
`Pass --source as JSON with a non-empty "nodes" array; each node must include "id".`,
`Execution sends one whiteboard.node batch_update request and preserves node ids in the request body.`,
`Use --idempotent-token for retry-safe batch_update requests; the token is sent as client_token only when provided.`,
},
Validate: wbNodeUpdateValidate,
DryRun: wbNodeUpdateDryRun,
Execute: wbNodeUpdateExecute,
}

View File

@@ -0,0 +1,239 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
func TestWhiteboardNodeUpdateValidate_SourceMissingIDTypedParam(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"source": `{"nodes":[{"type":"text","text":{"content":"hello"}}]}`,
}, nil)
err := wbNodeUpdateValidate(context.Background(), rt)
assertValidationParam(t, err, "--source", false)
}
func TestWhiteboardNodeUpdateDryRun_RequestShape(t *testing.T) {
t.Parallel()
rt := newTestRuntime(map[string]string{
"whiteboard-token": "test-board",
"idempotent-token": "update-token-12345",
"source": `{"nodes":[` +
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
`]}`,
}, nil)
dryRun := wbNodeUpdateDryRun(context.Background(), rt)
if dryRun == nil {
t.Fatal("wbNodeUpdateDryRun() returned nil")
}
var got 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"`
}
data, err := json.Marshal(dryRun)
if err != nil {
t.Fatalf("marshal dry-run: %v", err)
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry-run: %v\njson=%s", err, string(data))
}
if len(got.API) != 1 {
t.Fatalf("api len = %d, want 1; json=%s", len(got.API), string(data))
}
if got.API[0].Method != "PUT" {
t.Fatalf("method = %q, want PUT", got.API[0].Method)
}
if got.API[0].URL != "/open-apis/board/v1/whiteboards/test...oard/nodes/batch_update" {
t.Fatalf("url = %q, want masked batch_update URL", got.API[0].URL)
}
if got.API[0].Params["client_token"] != "update-token-12345" {
t.Fatalf("params.client_token = %#v, want update-token-12345", got.API[0].Params["client_token"])
}
nodes, ok := got.API[0].Body["nodes"].([]interface{})
if !ok || len(nodes) != 2 {
t.Fatalf("body.nodes = %#v, want two nodes", got.API[0].Body["nodes"])
}
wantText := []string{"hello A", "hello B"}
for i := range nodes {
node, ok := nodes[i].(map[string]interface{})
if !ok {
t.Fatalf("body.nodes[%d] = %T, want map; nodes=%#v", i, nodes[i], nodes)
}
if node["id"] != []string{"nodeA", "nodeB"}[i] {
t.Fatalf("body.nodes[%d].id = %#v", i, node["id"])
}
text, ok := node["text"].(map[string]interface{})
if !ok || text["content"] != wantText[i] {
t.Fatalf("body.nodes[%d].text = %#v, want content %q", i, node["text"], wantText[i])
}
}
}
func TestWhiteboardNodeUpdateExecute_BatchUpdatesNodes(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
var capturedQuery string
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"ids": []string{"nodeA", "nodeB"},
},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.RawQuery
},
}
reg.Register(stub)
source := `{"nodes":[` +
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
`]}`
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source, "--idempotent-token", "update-token-12345"}
if err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
assertNodeBatchUpdateCapturedBody(t, stub.CapturedBody, []string{"hello A", "hello B"})
if !strings.Contains(capturedQuery, "client_token=update-token-12345") {
t.Fatalf("query = %q, want client_token", capturedQuery)
}
if !strings.Contains(stdout.String(), `"ids": "nodeA,nodeB"`) {
t.Fatalf("stdout=%s, want ids nodeA,nodeB", stdout.String())
}
if !strings.Contains(stdout.String(), `"count": 2`) {
t.Fatalf("stdout=%s, want count 2", stdout.String())
}
}
func TestWhiteboardNodeUpdateExecute_WithoutIdempotentTokenOmitsClientToken(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
var capturedQuery string
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"ids": []string{"nodeA"},
},
},
OnMatch: func(req *http.Request) {
capturedQuery = req.URL.RawQuery
},
}
reg.Register(stub)
source := `{"nodes":[{"id":"nodeA","type":"text","text":{"content":"hello A"}}]}`
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source}
if err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if capturedQuery != "" {
t.Fatalf("query = %q, want empty when --idempotent-token is absent", capturedQuery)
}
}
func TestWhiteboardNodeUpdateExecute_BatchFailureReturnsAPIError(t *testing.T) {
factory, stdout, reg := newUpdateExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/board/v1/whiteboards/test-board/nodes/batch_update",
Body: map[string]interface{}{
"code": 1254001,
"msg": "node not found",
"data": map[string]interface{}{},
},
})
source := `{"nodes":[` +
`{"id":"nodeA","type":"text","text":{"content":"hello A"}},` +
`{"id":"nodeB","type":"text","text":{"content":"hello B"}}` +
`]}`
args := []string{"+node-update", "--whiteboard-token", "test-board", "--source", source}
err := runUpdateShortcut(t, WhiteboardNodeUpdate, args, factory, stdout)
if err == nil {
t.Fatal("expected batch update failure error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("errs.ProblemOf returned false for %T", err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("Category = %q, want %q", problem.Category, errs.CategoryAPI)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("error type = %T, want *errs.APIError reachable via errors.As", err)
}
}
func TestWhiteboardNodeUpdateTips_MentionTemporaryNonAtomicBehavior(t *testing.T) {
t.Parallel()
tips := strings.Join(WhiteboardNodeUpdate.Tips, "\n")
for _, want := range []string{"batch_update", "client_token", "one whiteboard.node batch_update request"} {
if !strings.Contains(tips, want) {
t.Fatalf("tips = %q, want substring %q", tips, want)
}
}
for _, banned := range []string{"fans out", "non-atomic", "Temporary behavior"} {
if strings.Contains(tips, banned) {
t.Fatalf("tips = %q, should not contain old fan-out wording %q", tips, banned)
}
}
}
func assertNodeBatchUpdateCapturedBody(t *testing.T, raw []byte, wantContent []string) {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(raw, &body); err != nil {
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(raw))
}
nodes, ok := body["nodes"].([]interface{})
if !ok || len(nodes) != len(wantContent) {
t.Fatalf("body.nodes = %#v, want %d nodes; body=%s", body["nodes"], len(wantContent), string(raw))
}
for i, rawNode := range nodes {
node, ok := rawNode.(map[string]interface{})
if !ok {
t.Fatalf("body.nodes[%d] = %T, want map; body=%s", i, rawNode, string(raw))
}
if _, exists := node["id"]; !exists {
t.Fatalf("body.nodes[%d].id absent; body=%s", i, string(raw))
}
text, ok := node["text"].(map[string]interface{})
if !ok || text["content"] != wantContent[i] {
t.Fatalf("body.nodes[%d].text = %#v, want content %q; body=%s", i, node["text"], wantContent[i], string(raw))
}
}
}

View File

@@ -41,6 +41,7 @@ lark-cli auth login --domain apps
| 看表 / 看结构 / 初始化多环境 / 导入导出数据 / 变更追溯 / 行级审计 / dev→online 发布 / 时间点恢复 / 查 DB 用量 | `+db-table-list``+db-table-get``+db-env-create``+db-data-export`/`+db-data-import``+db-changelog-list``+db-audit-status`/`+db-audit-enable`/`+db-audit-disable`/`+db-audit-list``+db-env-diff`/`+db-env-migrate``+db-recovery-diff`/`+db-recovery-apply``+db-quota-get` | [`lark-apps-db.md`](references/lark-apps-db.md) |
| 逐条执行 SQLSELECT / DML / DDL建表 / 改表 / 写 SQL 的平台规范 | `+db-execute` | [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md)(含「平台 SQL 规范」:审计列 / RLS / `user_profile` / 禁用 SQL / PG 陷阱) |
| 管理应用文件存储:上传/下载本地文件、列出/查看/删除已存文件、生成临时分享链接、查存储用量 | `+file-upload`/`+file-download`/`+file-list`/`+file-get`/`+file-sign`/`+file-delete`/`+file-quota-get` | [`lark-apps-file.md`](references/lark-apps-file.md) |
| 调试应用运行时缓存:查看/删除单个业务 key、清空指定环境缓存 | `+cache-get`/`+cache-delete`/`+cache-clear` | [`lark-apps-cache.md`](references/lark-apps-cache.md) |
| **部署/上线应用**"部署""上线""推上去并部署""发布到云端");查发布状态/历史 | 本地开发链路先按 [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md) 确认本次改动已 git commit + git push再用 `+release-create` / `+release-get`;查历史用 `+release-list` | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) |
| 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference |
| 创意模式html应用的评论相关操作 | 创意模式应用评论走 lark-drive 文档评论体系,读取 [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) 了解评论能力 | [`../lark-drive/SKILL.md`](../lark-drive/SKILL.md) |

View File

@@ -0,0 +1,61 @@
# apps cache 域命令(应用运行时缓存调试)
调试妙搭应用的运行时缓存:查看某个缓存 key 的内容、删除单个 key、清空某个环境的全部缓存。缓存是应用为了加速而临时存放的数据删除或清空后应用下次用到时会自动重新取最新数据。命令事实以 `lark-cli apps +<cmd> --help` 为准;认证、`--as user`、exit 码、`_notice` 等通用处理见 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 与本域 [`SKILL.md`](../SKILL.md)。
## 何时用
用户要排查「某个缓存 key 里存的是什么 / 有没有命中」、想删掉某个 key 让应用下次拿到最新数据、或想清空某个环境的缓存做快速恢复时。
## 命令一览
| 命令 | 做什么 | 关键参数 |
|---|---|---|
| `+cache-get` | 查一个缓存 key 的内容与信息 | `--key``--environment``--format` |
| `+cache-delete` | 删一个缓存 key重复删不会报错不需 `--yes` | `--key``--environment` |
| `+cache-clear` | 清空指定环境下的全部缓存(**高危** | `--environment``--yes` |
> 所有命令都需 `--app-id`。
## 约定(先读)
- **环境 `--environment dev|online`(可省略)**:缓存按运行环境隔离。不指定时按应用当前的环境配置自动选择——有多环境的应用默认落到开发环境 `dev`,没有多环境的就是线上 `online`;返回结果里的 `environment` 会告诉你这次实际操作的是哪个环境。想固定就显式传。
- **缓存 key 用 `--key` 传**:传业务里使用的那个 key是否合法非空、长度等由服务端校验不合法会返回错误。
- **风险分级**`+cache-clear` 会清掉整个环境的缓存,是高危操作,不带 `--yes` 会被确认关卡拦下;`+cache-delete` 只删单个 key、影响小不需 `--yes`
- **`+cache-get` 的内容有两种展示**`--format json`(默认)原样返回缓存内容,适合精确比对;`--format pretty` 会把内容格式化展开,更便于阅读。
## 各命令
### +cache-get
`--key` 查单个缓存。命中时返回是否存在、剩余有效期TTL、内容及其大小未命中或已过期时只返回 `exists=false`、不带内容。
> 每次查询都会连内容一起返回(没有「只看信息、不取内容」的模式),内容可能较大——只是想确认「在不在 / 还有多久过期」时,留意别占用太多上下文。
```bash
lark-cli apps +cache-get --app-id app_xxx --key spotbonus:2026:winners:list:v1
lark-cli apps +cache-get --app-id app_xxx --environment online --key <key> --format pretty
```
### +cache-delete
删一个缓存 key。**重复删、或删一个本就不存在的 key都算成功**(返回删除数量 0、不会报错删中则返回删除数量 1。删掉后应用下次会自动重新取最新数据影响小故不需 `--yes`
```bash
lark-cli apps +cache-delete --app-id app_xxx --environment dev --key <key>
```
### +cache-clear高危
清空当前应用在**指定环境**下的全部缓存,用于定位不到具体 key 时的快速恢复。影响面是整个环境,必须带 `--yes`;返回本次清除的 key 数量。动手前可先 `--dry-run` 预览将要执行的操作。
```bash
lark-cli apps +cache-clear --app-id app_xxx --environment dev --yes
```
## 错误与边界
- **key 不合法 / 缓存服务暂时不可用**:命令会返回带说明的错误,按 `error.hint` 转述给用户;「服务暂时不可用」这类可稍后重试。
## Agent 规则
- **写操作先定环境**`+cache-clear` / `+cache-delete` 不指定 `--environment` 时会落到自动选中的环境——**没有多环境的应用会直接作用到线上 `online`(生产)**。不确定应用有没有多环境时,写操作显式传 `--environment`;纯查看(`+cache-get`)影响小,可以省略。
- **`+cache-clear` 会清掉整个环境的缓存**:执行前先跟用户确认环境无误、说明会清掉该环境全部缓存。已明确授权可直接带 `--yes`;遇到确认关卡(`confirmation_required`exit 10按 lark-shared 约定与用户确认后再补 `--yes` 重试,不要静默追加。
- **排查缓存内容优先用 `+cache-get`**:想看结构化、易读的内容用 `--format pretty`;想拿原始内容做精确比对用默认 JSON。
- **删 key 前先对齐 key**:用户只描述了业务含义、没给准确 key 时,先确认再删——删错影响也有限(应用会自动重建),但仍应避免误删。

View File

@@ -1,7 +1,7 @@
---
name: lark-base
version: 1.2.3
description: "飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入转 lark-drive认证/授权转 lark-shared。"
description: "飞书多维表格Base操作建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、workflow、角色权限遇到 Base/多维表格/bitable 或 /base/ 链接时使用。文件导入/导出转 lark-drive认证/授权转 lark-shared。"
metadata:
requires:
bins: ["lark-cli"]
@@ -23,14 +23,15 @@ metadata:
不要使用本 skill
- 只是认证、初始化配置、切换身份、处理 scope 或权限授权恢复,转 `lark-shared`
- 把本地 Excel / CSV / `.base` 导入成 Base`lark-drive +import --type bitable`
- 把本地文件导入成 Base或将 Base 导出为本地文件,转 `lark-drive`
- 泛化数据分析、字段设计、公式讨论,但没有 Base/多维表格上下文。
## 使用边界
- Base 业务操作只使用 `lark-cli base +...` shortcut不使用旧聚合式 `+table / +field / +record / +view / +history / +workspace`
- 执行 update 前必须先查当前 shortcut 的 `--help` 或对应 reference。若命令要求完整配置首次请求必须基于可信的当前配置执行 read-modify-write只修改用户明确指定的内容保留其他仍适用的可写配置并按命令要求的结构提交。若命令支持局部delta update按其契约提交最小合法 payload不得以不完整请求试错补参。
- 用户要把 Excel / CSV / `.base` 导入成 Base 时,先`lark-cli drive +import --type bitable`导入完成后再回到 Base 命令。
- 本地文件与 Base 之间的导入/导出`lark-drive`,具体格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;导入完成后再回到 Base 命令。
- 在线复制 Base 使用 `+base-copy`,不要绕行导出/导入。
- 认证、初始化、scope、身份切换、权限不足恢复属于 `lark-shared`Base 文档只保留会影响 Base 路径选择的权限规则。
## 先获取 Base Token 和所需 ID
@@ -49,6 +50,7 @@ metadata:
|---|---|---|
| 查 Base 本体 | `+base-get` | 用返回确认 Base 名称、owner、权限和可继续操作的 token |
| 创建/复制 Base | `+base-create` / `+base-copy` | 新建时强烈推荐用 `--table-name` + `--fields` 同时配置新 Base 里唯一一个初始数据表的 name 和 schema写入后报告新 Base 标识和 `permission_grant` |
| Base 文件导入/导出 | 转 `lark-drive` | 文件格式、参数、路径限制和仅结构导出规则由 `lark-drive` 负责;在线复制走 `+base-copy` |
| 查看 Base 内资源目录 | `+base-block-list` | 想先了解一个 Base 里有哪些 table/docx/dashboard/workflow/folder 时优先用它;返回 ID 关系和 fewshot 看 `--help` |
| 管理 Base 内资源目录 | `+base-block-create/move/rename/delete` | 创建或整理 Base 直接管理的 folder/table/docx/dashboard/workflow资源内容继续用对应命令 |
| 管理数据表 | `+table-list/get/create/update/delete` | 处理 table 的列出、详情、创建、重命名和删除 |
@@ -63,8 +65,9 @@ metadata:
| 公式字段 | `+field-create/update --json '{"type":"formula",...}'` | 必读 [formula-field-guide.md](references/formula-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
| Lookup 字段 | `+field-create/update --json '{"type":"lookup",...}'` | 必读 [lookup-field-guide.md](references/lookup-field-guide.md),读后再加隐藏确认 flag `--i-have-read-guide` |
| 表单提交 | `+form-submit` | 先读 [lark-base-form-detail.md](references/lark-base-form-detail.md) 获取题目、filter 和附件所需 `base_token`;提交 JSON 读 [lark-base-form-submit.md](references/lark-base-form-submit.md) |
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | 读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
| 其他表单管理 | `+form-list/get/detail/create/update/delete` / `+form-questions-list/delete` | `+form-detail` 读 [lark-base-form-detail.md](references/lark-base-form-detail.md)删除前确认目标表单 |
| 表单题目创建/更新 | `+form-questions-create` / `+form-questions-update` | Base 内表单按 table 管理;先确定并复用真实 `table_id`读 [lark-base-form-questions-create.md](references/lark-base-form-questions-create.md) / [lark-base-form-questions-update.md](references/lark-base-form-questions-update.md);题目显隐条件 `visible_rule` 结构见公共协议 [lark-base-filter-condition.md](references/lark-base-filter-condition.md) |
| Base 内表单管理 | `+form-list/get/create/update/delete` / `+form-questions-list/delete` | 缺少或不确定归属时,先用 `+table-list``+base-block-list` 取得真实 `table_id`;这些命令使用 `--base-token + --table-id` 并在整个工作流中复用同一 `table_id`删除前确认目标表单 |
| 分享表单详情 | `+form-detail --share-token <share_token>` | 只接受表单分享链接里的 `share_token`,不要传 `--base-token` / `--form-id`;提交前读 [lark-base-form-detail.md](references/lark-base-form-detail.md) |
| 仪表盘与组件 | `+dashboard-*` / `+dashboard-block-*` | 提到图表/看板/block 时先读 [lark-base-dashboard.md](references/lark-base-dashboard.md);组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md);读取图表计算结果用 `+dashboard-block-get-data` |
| Workflow | `+workflow-*` | 创建/更新或理解 steps 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)list/get/enable/disable 只处理 workflow ID 与启停状态 |
| 高级权限与角色 | `+advperm-*` / `+role-*` | 角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md);角色 create/update 或解读完整配置再读权限 JSON SSOT [role-config.md](references/role-config.md);系统角色不可删除;关闭高级权限会影响自定义角色 |
@@ -116,6 +119,9 @@ metadata:
## 表单与视图细节
- Base 内表单 list/get/create/update/delete 和题目管理都属于具体数据表:第一个管理命令前必须已有归属明确的真实 `table_id`;缺失或归属不明确时才用 `+table-list``+base-block-list` 定位,已有真实 ID 时直接复用。后续管理命令始终传同一 `base_token + table_id``+form-detail` 是分享表单入口,标识域不同,只使用 `share_token`
- 表单问题由数据表字段承载question `id` 就是 `field_id`。创建问题前先 `+form-questions-list`;除非用户明确要求同名的独立问题,否则标题已存在时优先用 `+form-questions-update` 修改必填状态、标题或描述,不要先创建同名问题再删除旧问题。
- `+form-questions-delete` 会删除承载问题的数据表字段。主字段问题不可删除;不要把主字段 ID 放入 `--question-ids`,需要修改时使用 `+form-questions-update`
- `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type``required``filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。
- `+form-questions-update` 是题目配置全量覆盖,不是 patch未传字段会回落默认值传空字符串 / `null` / 空数组会直接写入空或清空。更新前先 `+form-questions-list` 读取当前题目,把要保留的 `title` / `description` / `required` / `option_display_mode` / `visible_rule` 等字段带回请求。
- 表单附件不要写进 `fields`,放在 `--json.attachments`;提交附件时必须同时传表单所属 Base 的 `--base-token`

View File

@@ -137,9 +137,12 @@ lark-cli base +form-questions-create \
> [!CAUTION]
> 这是**写入操作** — 执行前必须向用户确认。
1.`+form-questions-list` 查看现有问题
2. 确认要添加的问题内容
3. 执行命令并报告新建的问题 ID
1.确定表单所属的真实 `table_id`,并在整个表单管理工作流中复用它;仅在 ID 缺失或归属不明确时调用 `+table-list`
2. `+form-questions-list` 查看现有问题。问题 `id` 是承载该问题的 `field_id`,不是独立于数据表的临时 ID。
3. 除非用户明确要求同名的独立问题,否则目标标题已经存在时用 `+form-questions-update` 更新必填状态、标题或描述;不要创建同名问题后再删除旧问题。
4. 创建确实不存在的问题,或用户明确要求的同名独立问题,并报告新建的问题 ID。
`+form-questions-delete` 会删除承载问题的数据表字段,不能删除主字段问题。不要通过“新建重复问题再删除旧问题”来替换主字段。
## 参考

View File

@@ -6,6 +6,7 @@ This guide is the entry point for Base advanced permissions and roles. Use it to
| Goal | Command | Notes |
|------|---------|-------|
| Check advanced permission status | `+base-get` | Read `data.base.is_advanced`. There is no `+advperm-get` command. |
| Enable advanced permissions | `+advperm-enable` | Required before creating or updating roles. Caller must be a Base admin. |
| Disable advanced permissions | `+advperm-disable` | High-risk write. Disabling invalidates existing custom roles. |
| Locate roles | `+role-list` | Returns role summaries. Use `+role-get` for full config. |
@@ -14,6 +15,16 @@ This guide is the entry point for Base advanced permissions and roles. Use it to
| Update a role | `+role-update` | Delta merge. Read current config first, then send only intended changes. |
| Delete a role | `+role-delete` | Custom roles only. System roles cannot be deleted. |
## Required order
At the start of a role workflow, before the first `+role-list`, `+role-get`, `+role-create`, `+role-update`, or `+role-delete` call:
1. Run `lark-cli base +base-get --base-token <base_token>` and inspect `data.base.is_advanced`.
2. If `is_advanced` is `false`, run `+advperm-enable` before the role command. If the user did not authorize enabling advanced permissions, stop and explain the required precondition.
3. Run the requested role commands only after `is_advanced` is `true` or `+advperm-enable` succeeds. Reuse that confirmed status for later role calls in the same workflow.
Do not probe with `+advperm-get`: that command is not supported. Do not use an empty `+role-list` response to infer the advanced permission status; a disabled Base can also return an empty list.
## Safety boundaries
- Role operations require advanced permissions to be enabled and the caller to be a Base admin.

View File

@@ -154,12 +154,34 @@
"table_rule_map": {
"订单表": {
"perm": "edit",
"view_rule": { "..." : "..." },
"record_rule": { "..." : "..." },
"field_rule": { "..." : "..." }
"view_rule": {
"allow_edit": true,
"visibility": { "all_visible": true }
},
"record_rule": {
"record_operations": ["add", "delete"],
"other_record_all_read": true
},
"field_rule": {
"field_perm_mode": "all_edit"
}
},
"用户表": {
"perm": "read_only"
"perm": "read_only",
"view_rule": {
"allow_edit": false,
"visibility": { "all_visible": true }
},
"record_rule": {
"record_operations": [],
"other_record_all_read": true
},
"field_rule": {
"field_perm_mode": "all_read"
}
},
"内部表": {
"perm": "no_perm"
}
}
}
@@ -172,7 +194,11 @@
| `record_rule` | RecordRule | 记录权限配置 |
| `field_rule` | FieldRule | 字段权限配置 |
**注意**: 当 `perm``no_perm` 时,`view_rule``record_rule``field_rule` 均无须再设置。
**`+role-create` 硬约束**:
-`perm``no_perm` 时,不要设置 `view_rule``record_rule``field_rule`
-`perm` 为其他值时,必须同时提供完整的 `view_rule``record_rule``field_rule`,缺少任意一项都会导致创建失败。
- `+role-update` 是 delta merge只提交要修改的字段不要为局部更新补造未变更配置。
---

View File

@@ -2,7 +2,7 @@
name: lark-whiteboard
version: 1.0.0
description: >
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用多种格式更新画板内容。
飞书画板:查询和编辑飞书云文档中的画板。支持导出画板为预览图片、导出原始节点结构、使用多种格式更新画板内容,并支持按节点增量创建、更新和删除
当用户需要查看画板内容、导出画板图片、编辑画板时使用此 skill。不负责飞书云文档内容编辑lark-doc、文档内嵌电子表格/Baselark-sheets / lark-base
metadata:
requires:
@@ -28,7 +28,10 @@ metadata:
| 导出 SVG 矢量图 | [`+export --output-type svg`](references/lark-whiteboard-export.md) |
| 获取画板的 Mermaid/PlantUML 代码 | [`+export --output-type source`](references/lark-whiteboard-export.md) |
| 检查画板是否由代码绘制 | [`+export --output-type source`](references/lark-whiteboard-export.md) |
| 仅微调节点文字/颜色 | `+export --output-type raw` → 手动改 JSON → `+update --input_format raw` |
| 定位节点 id / 查看原始节点结构 | [`+export --output-type raw`](references/lark-whiteboard-export.md) |
| 已知 node id, 微调文字/颜色/样式 | [`+node-update`](references/lark-whiteboard-node-update.md); 先用 `+export --output-type raw` 定位节点 |
| 追加已编译好的 OpenAPI 节点 | [`+node-create`](references/lark-whiteboard-node-create.md); 节点建议由 `npx -y @larksuite/whiteboard-cli@^0.2.13 --to openapi` 生成后整理成 `{ "nodes": [...] }` |
| 删除已知节点 | [`+node-delete`](references/lark-whiteboard-node-delete.md); 删除前先确认 node id, 真实执行需要 `--yes` |
| 用户**已提供** Mermaid/PlantUML/SVG 代码,或明确指定用该格式 | 自己生成/使用代码 → [`+update --input_format mermaid/plantuml/svg`](references/lark-whiteboard-update.md) |
| 新建/创作复杂图表(架构/流程/组织等) | → **[§ 创作 Workflow](references/lark-whiteboard-workflow.md#创作-workflow)** |
| 修改/重绘已有画板 | → **[§ 修改 Workflow](references/lark-whiteboard-workflow.md#修改-workflow)** |
@@ -39,6 +42,9 @@ metadata:
|---------------------------------------------------|---|
| [`+export`](references/lark-whiteboard-export.md) | 导出画板为预览图片、SVG 矢量图、代码或原始节点结构。 |
| [`+update`](references/lark-whiteboard-update.md) | 更新画板,支持 PlantUML、Mermaid、SVG 或 OpenAPI 原生格式 |
| [`+node-create`](references/lark-whiteboard-node-create.md) | 向已有画板追加 OpenAPI 节点;适合已由工具生成节点数据的增量新增 |
| [`+node-update`](references/lark-whiteboard-node-update.md) | 按节点 id 批量更新已有节点;执行层发起一次 batch_update 请求 |
| [`+node-delete`](references/lark-whiteboard-node-delete.md) | 按节点 id 删除已有节点;高风险写操作,执行前必须确认目标节点 |
---

View File

@@ -18,7 +18,7 @@
- `preview`:预览图片。推荐 `--output ./preview` 这类无后缀文件路径CLI 会按实际图片类型保存为 `./preview.png``./preview.jpg`。如果 `--output` 是目录,会保存为该目录下的 `whiteboard_<whiteboard-token>.png/.jpg`;如果显式写了后缀,需要和实际图片类型匹配。`--overwrite` 检查的是补齐后缀后的最终路径,例如返回 PNG 时 `--output ./preview` 对应覆盖 `./preview.png`
- `svg`:导出画板为标准 SVG 矢量图。可用于 SVG 编辑后回写画板(见 [`routes/svg-edit.md`](../routes/svg-edit.md))。注意:导出为纯视觉快照,思维导图层级、表格结构、连接器绑定等语义信息会丢失。
- `source`PlantUML/Mermaid 代码。仅限画板内有且仅有一个 PlantUML/Mermaid 图时,才可导出代码,否则会在返回值中告知不存在/有多个节点。
- `raw`:飞书 OpenAPI 原生画板节点格式。这一 json 格式不适合直接编辑复杂布局或内容,建议仅限于需要修改简单的文本内容/颜色等细节时使用。需要进行更复杂设计/修改时,建议参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
- `raw`:飞书 OpenAPI 原生画板节点格式。主要用于定位 `data.nodes[].id` 和核对节点字段。已知 node id 的局部修改优先用 [`+node-update`](./lark-whiteboard-node-update.md),删除用 [`+node-delete`](./lark-whiteboard-node-delete.md);不要手动改 raw JSON 后用 `+update --input_format raw` 做节点级微调。复杂设计/修改参考 [§ 渲染 & 写入画板](../SKILL.md#渲染--写入画板)。
## 示例

View File

@@ -0,0 +1,97 @@
# whiteboard +node-create
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。画板节点操作默认使用 `--as user`。
向已有画板追加 OpenAPI 节点。它适合局部新增已编译好的节点, 不适合从零创作复杂图表。
## 适用场景
- 已经知道 `whiteboard-token`, 且拥有画板编辑权限。
- 已经有可追加的 OpenAPI `nodes[]`
- 需要向已有画板追加节点, 而不是覆盖整图。
## 不适用场景
- 从零创作复杂图表, 或需要自动布局、批量排版、复杂连线计算。
- 只有 DSL / Mermaid / SVG, 还没有转换成 OpenAPI 节点。
- 只想在文档正文里插入或移动画板块, 这属于 `lark-doc`
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--whiteboard-token` | 是 | 画板 token。 |
| `--source` | 是 | JSON, 必须包含非空 `nodes` 数组。支持 `@path` 文件读取或 `-` stdin。 |
| `--idempotent-token` | 否 | 幂等 token, 最少 10 个字符。重试同一次逻辑新增时复用同一个值。 |
## 输入
`nodes[]` 必须是飞书 OpenAPI 画板节点, 不是 whiteboard-cli DSL。不要把 `{"type":"shape","shape":...}` 这类 DSL 节点直接传给本命令。
推荐先用 `npx -y @larksuite/whiteboard-cli@^0.2.13 --to openapi --format json` 生成 OpenAPI 结果, 再整理成 `{ "nodes": [...] }`
```json
{
"nodes": [
{
"id": "tmpNode",
"type": "composite_shape",
"x": 0,
"y": 0,
"width": 260,
"height": 45,
"text": {
"text": "hello",
"font_weight": "regular",
"font_size": 14,
"horizontal_align": "center",
"vertical_align": "mid"
},
"style": {
"border_color": "#3370ff",
"border_width": "narrow",
"border_style": "solid",
"fill_color": "#e8f3ff"
},
"composite_shape": {
"type": "round_rect"
}
}
]
}
```
## 示例
```bash
lark-cli whiteboard +node-create \
--whiteboard-token <whiteboard_token> \
--source @./nodes.json \
--idempotent-token <10+字符唯一串> \
--as user \
--dry-run
lark-cli whiteboard +node-create \
--whiteboard-token <whiteboard_token> \
--source @./nodes.json \
--idempotent-token <10+字符唯一串> \
--as user
```
## 输出
JSON 输出使用 `data.ids`, 多个 id 用逗号拼接:
```json
{
"data": {
"ids": "o2:5"
}
}
```
## Safety
- 写入前先用 `--dry-run` 检查 method、URL、params 和 body。
- 对手写节点尤其要先 dry-rundry-run 只能验证请求结构, 不能证明节点语义一定可插入。
- 复杂图表继续走 `whiteboard-cli -> +update` 或 workflow 路径, 不要把 `+node-create` 当作默认创作入口。

View File

@@ -0,0 +1,75 @@
# whiteboard +node-delete
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。画板节点操作默认使用 `--as user`。
按 node id 删除已有节点。这是高风险写操作, 只能删除已经确认的目标节点。
## 适用场景
- 已经知道 `whiteboard-token`, 且拥有画板编辑权限。
- 已经确认要删除的 node id。
- 需要删除已有画板中的局部节点。
## 不适用场景
- 不知道目标 node id。
- 只是想隐藏、移动或更新节点。
- 需要清空或整体替换画板。
## 定位节点
先导出 raw 节点结构:
```bash
lark-cli whiteboard +export \
--whiteboard-token <whiteboard_token> \
--output-type raw \
--as user
```
从返回的 `data.nodes[].id` 读取目标 node id。不要删除从上下文猜测出来的 ambient 节点。
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--whiteboard-token` | 是 | 画板 token。 |
| `--node-ids` | 是 | 要删除的 node id, 多个 id 用英文逗号分隔。 |
| `--idempotent-token` | 否 | 幂等 token, 最少 10 个字符。重试同一次逻辑删除时复用同一个值。 |
| `--yes` | 真实执行需要 | 高风险写操作确认。先 dry-run, 确认目标后再传。 |
## 示例
```bash
lark-cli whiteboard +node-delete \
--whiteboard-token <whiteboard_token> \
--node-ids <node_id_1>,<node_id_2> \
--idempotent-token <10+字符唯一串> \
--as user \
--dry-run
lark-cli whiteboard +node-delete \
--whiteboard-token <whiteboard_token> \
--node-ids <node_id_1>,<node_id_2> \
--idempotent-token <10+字符唯一串> \
--as user \
--yes
```
## 输出
```json
{
"data": {
"ids": "o2:5,o2:6",
"count": 2
}
}
```
## Safety
- 删除前必须用 `+export --output-type raw` 确认 node id。
- 先运行 `--dry-run`, 检查 method 是 `DELETE`, URL 是 `/nodes/batch_delete`, body 是 `{"ids":[...]}`
- 只有确认目标节点后才传 `--yes`
- 不要因为用户说“删掉这个”就删除最近消息里的节点;缺少 node id 时先导出 raw 或要求定位依据。

View File

@@ -0,0 +1,100 @@
# whiteboard +node-update
> **前置条件:** 先阅读 [`../../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。画板节点操作默认使用 `--as user`。
按 node id 更新已有节点字段。CLI 输入是批量形态, 执行层会发起一次 `batch_update` OpenAPI 请求。
## 适用场景
- 已经知道 `whiteboard-token`, 且拥有画板编辑权限。
- 已经知道目标 node id。
- 需要局部修改文字、颜色、样式、位置等节点字段。
## 不适用场景
- 不知道目标 node id。
- 需要重绘复杂图表或重新布局。
- 想替换整个画板内容。
## 定位节点
先导出 raw 节点结构:
```bash
lark-cli whiteboard +export \
--whiteboard-token <whiteboard_token> \
--output-type raw \
--as user
```
从返回的 `data.nodes[].id` 读取目标 node id, 再构造更新输入。
## 参数
| 参数 | 必填 | 说明 |
|---|---|---|
| `--whiteboard-token` | 是 | 画板 token。 |
| `--source` | 是 | JSON, 必须包含非空 `nodes` 数组, 每个 node 必须包含 `id`。支持 `@path` 文件读取或 `-` stdin。 |
| `--idempotent-token` | 否 | 幂等 token, 最少 10 个字符;非空时作为 `client_token` 随 batch_update 请求发送。 |
## 输入
CLI 输入保持批量形态:
```json
{
"nodes": [
{
"id": "o2:5",
"type": "composite_shape",
"text": {
"text": "updated",
"font_weight": "regular",
"font_size": 14,
"horizontal_align": "center",
"vertical_align": "mid"
}
}
]
}
```
执行时所有节点会保持在同一个请求中:
- `PUT /open-apis/board/v1/whiteboards/:whiteboard_id/nodes/batch_update`
- body 为 `{"nodes": [...]}`, 节点内的 `id` 会保留。
- `--idempotent-token` 非空时, query 参数带 `client_token=<token>`
## 示例
```bash
lark-cli whiteboard +node-update \
--whiteboard-token <whiteboard_token> \
--source @./node-updates.json \
--idempotent-token <10+字符唯一串> \
--as user \
--dry-run
lark-cli whiteboard +node-update \
--whiteboard-token <whiteboard_token> \
--source @./node-updates.json \
--idempotent-token <10+字符唯一串> \
--as user
```
## 输出
```json
{
"data": {
"ids": "o2:5",
"count": 1
}
}
```
## Safety
- 多节点更新前先使用 `--dry-run` 检查 batch_update method、URL、params 和 body。
- batch_update 后端不承诺跨阶段事务回滚;如服务端提示请求未完整完成, 需用 `+export --output-type raw` 读回目标节点确认状态。
- 不要在节点更新失败时自动回退到 `+update --overwrite`, 除非用户明确要求替换整个画板。

View File

@@ -30,6 +30,12 @@
├─ 返回 Mermaid/PlantUML 代码
│ → 在原代码上修改 → +update --input_format mermaid/plantuml
├─ 无代码SVG/DSL 或其他方式绘制的画板)
│ ├─ 已知 node id, 只需局部微调
│ │ → +export --output-type raw 确认节点 → +node-update → +export --output-type raw 或 preview 复验
│ ├─ 已知 node id, 需要删除局部节点
│ │ → +export --output-type raw 确认节点 → +node-delete --dry-run → +node-delete --yes → preview 复验
│ ├─ 已生成 OpenAPI nodes[] 且只需追加
│ │ → +node-create --dry-run → +node-create → +export --output-type raw 或 preview 复验
│ ├─ 需纯新增(思维导图、流程图、时序图、类图、饼图、甘特图)图表节点
│ │ → +export --output-type preview → 看图 → +export --output-type raw → 确定新节点坐标和层级 → [§ 渲染 & 写入画板]
│ └─ 其他改动(几何变动/增删元素/结构调整/混合编辑等)
@@ -83,5 +89,6 @@ diagram.png ← 渲染结果
- Mermaid / PlantUML / SVG 产物直接用对应的 `mermaid` / `plantuml` / `svg` 写入。
- 只有 DSL 产物或已明确需要 OpenAPI 原生节点格式时,才先用 `npx -y @larksuite/whiteboard-cli@^0.2.13 --to openapi --format json` 转换,再用 `raw` 写入。
- 如果目标是向已有画板追加已编译好的 OpenAPI `nodes[]`, 优先用 [`whiteboard +node-create`](./lark-whiteboard-node-create.md), 不要为了追加节点覆盖整图。
具体命令示例、`--overwrite``--idempotent-token``--as user/bot` 的使用方式,统一参考 [`whiteboard +update`](./lark-whiteboard-update.md)。

View File

@@ -55,3 +55,26 @@ func TestBaseFormDetailDryRun_MissingShareToken(t *testing.T) {
assert.NotEqual(t, 0, result.ExitCode)
assert.Contains(t, result.Stderr, "share-token")
}
func TestBaseFormListDryRun_UsesBaseAndTableIdentifiers(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-list",
"--base-token", "basXXXX",
"--table-id", "tblXXXX",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/base/v3/bases/basXXXX/tables/tblXXXX/forms")
assert.Contains(t, output, `"method": "GET"`)
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestBaseFormQuestionsCreateDryRun(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-questions-create",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--form-id", "vew_x",
"--questions", `[{"type":"text","title":"Risk","required":true}]`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, "/open-apis/base/v3/bases/app_x/tables/tbl_x/forms/vew_x/questions", clie2e.DryRunGet(out, "api.0.url").String(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
require.Equal(t, "text", clie2e.DryRunGet(out, "api.0.body.questions.0.type").String(), out)
require.Equal(t, "Risk", clie2e.DryRunGet(out, "api.0.body.questions.0.title").String(), out)
require.True(t, clie2e.DryRunGet(out, "api.0.body.questions.0.required").Bool(), out)
}
func TestBaseFormQuestionsCreateDryRunRejectsInvalidInput(t *testing.T) {
setBaseDryRunConfigEnv(t)
tests := []struct {
name string
input string
message string
}{
{name: "malformed JSON", input: "{", message: "must be a valid JSON array"},
{name: "non-array JSON", input: "{}", message: "must be a valid JSON array"},
{name: "null", input: "null", message: "must be a non-null JSON array"},
{name: "non-object item", input: "[1]", message: "item 1 must be an object"},
{name: "missing title", input: `[{"type":"text"}]`, message: `item 1 must include a non-empty string "title"`},
{name: "blank title", input: `[{"title":" ","type":"text"}]`, message: `item 1 must include a non-empty string "title"`},
{name: "missing type", input: `[{"title":"Risk"}]`, message: `item 1 must include a non-empty string "type"`},
{name: "non-string type", input: `[{"title":"Risk","type":1}]`, message: `item 1 must include a non-empty string "type"`},
{name: "more than ten items", input: `[{},{},{},{},{},{},{},{},{},{},{}]`, message: "must contain at most 10 items"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+form-questions-create",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--form-id", "vew_x",
"--questions", tt.input,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
require.Equal(t, "validation", gjson.Get(result.Stderr, "error.type").String(), result.Stderr)
require.Equal(t, "invalid_argument", gjson.Get(result.Stderr, "error.subtype").String(), result.Stderr)
require.Equal(t, "--questions", gjson.Get(result.Stderr, "error.param").String(), result.Stderr)
require.Contains(t, gjson.Get(result.Stderr, "error.message").String(), tt.message)
require.Empty(t, result.Stdout)
})
}
}
func TestBaseFormQuestionsCreateHelpShowsExistingQuestionGuard(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"base", "+form-questions-create", "--help"},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
require.Contains(t, strings.ToLower(result.Stdout), "form may already contain questions")
require.Contains(t, result.Stdout, "+form-questions-list")
require.Contains(t, result.Stdout, "+form-questions-update")
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package base
import (
"path/filepath"
"runtime"
"testing"
"github.com/larksuite/cli/internal/vfs"
"github.com/stretchr/testify/require"
)
func TestBaseSkillRoutesFileImportExportToDrive(t *testing.T) {
_, currentFile, _, ok := runtime.Caller(0)
require.True(t, ok)
skillPath := filepath.Join(filepath.Dir(currentFile), "..", "..", "..", "skills", "lark-base", "SKILL.md")
content, err := vfs.ReadFile(skillPath)
require.NoError(t, err)
skill := string(content)
require.Contains(t, skill, "文件导入/导出转 lark-drive")
require.Contains(t, skill, "本地文件与 Base 之间的导入/导出转 `lark-drive`")
require.Contains(t, skill, "在线复制走 `+base-copy`")
require.NotContains(t, skill, "--only-schema")
require.NotContains(t, skill, "--output-dir")
require.NotContains(t, skill, "/tmp/")
}

View File

@@ -1,17 +1,21 @@
# Base CLI E2E Coverage
## Metrics
- Denominator: 78 leaf commands
- Covered: 22
- Coverage: 28.2%
- Denominator: 87 leaf commands
- Covered: 28
- Coverage: 32.2%
## Summary
- TestBase_BasicWorkflow: proves `+base-create`, `+base-get`, `+table-create`, `+table-get`, and `+table-list`; key `t.Run(...)` proof points are `get base as bot`, `get table as bot`, and `list tables and find created table as bot`.
- TestBaseBlockDryRun: proves the five `+base-block-*` shortcuts request shapes without touching live data.
- TestBaseFieldCreateDryRunArrayCompat: proves `+field-create` dry-run request shape for the internal JSON-array compatibility path.
- TestBaseFormQuestionsCreateDryRun: proves `+form-questions-create` preserves its POST body and renders the existing-question guard in command help.
- TestBaseFormDetailDryRun / TestBaseFormSubmitDryRun: prove shared-form detail and submission request shapes.
- TestBaseDashboardBlockGetDataDryRun: proves dashboard block data request shapes and identifier handling.
- TestBaseRecordBatchUpdatePerRecordDryRun: proves `+record-batch-update` preserves the per-record `update_records` request shape.
- TestBaseRecordBatchUpdatePerRecordWorkflow: creates two records, updates different field types in one request, asserts the minimal response contract, reads both records back, verifies a missing record ID is not prevalidated, and cleans up the temporary Base.
- TestBase_RoleWorkflow: proves `+advperm-enable`, `+role-create`, `+role-list`, `+role-get`, and `+role-update`; key `t.Run(...)` proof points are `list as bot`, `get as bot`, and `update as bot`.
- TestBaseFormListDryRun_UsesBaseAndTableIdentifiers: proves `+form-list` dry-run request shape uses Base and table identifiers in the endpoint.
- TestBaseFormQuestionsCreateVisibleRuleDryRun / TestBaseFormQuestionsUpdateVisibleRuleDryRun: prove `+form-questions-create` / `+form-questions-update` dry-run request shape and that the optional `visible_rule` display condition is transcribed verbatim into the request body.
- Cleanup note: `+table-delete` and `+role-delete` only run in cleanup and are intentionally left uncovered.
- Blocked area: dashboard, field, most record operations, form, view, and workflow operations still lack deterministic create/read/update workflows in this suite.
@@ -34,6 +38,7 @@
| ✕ | base +dashboard-block-create | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-delete | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-get | shortcut | | none | dashboard workflows not covered |
| ✓ | base +dashboard-block-get-data | shortcut | base_dashboard_block_get_data_dryrun_test.go | `--base-token`; `--dashboard-id`; `--block-id`; dry-run only | request shape and identifier handling |
| ✕ | base +dashboard-block-list | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-block-update | shortcut | | none | dashboard workflows not covered |
| ✕ | base +dashboard-create | shortcut | | none | dashboard workflows not covered |
@@ -50,12 +55,14 @@
| ✕ | base +field-update | shortcut | | none | field workflows not covered |
| ✕ | base +form-create | shortcut | | none | form workflows not covered |
| ✕ | base +form-delete | shortcut | | none | form workflows not covered |
| ✓ | base +form-detail | shortcut | base_form_detail_dryrun_test.go::TestBaseFormDetailDryRun | `--share-token`; dry-run only | shared-form request shape |
| ✕ | base +form-get | shortcut | | none | form workflows not covered |
| | base +form-list | shortcut | | none | form workflows not covered |
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
| | base +form-list | shortcut | base_form_detail_dryrun_test.go::TestBaseFormListDryRun_UsesBaseAndTableIdentifiers | `--base-token`; `--table-id`; dry-run only | request shape only |
| ✓ | base +form-questions-create | shortcut | TestBaseFormQuestionsCreateVisibleRuleDryRun; base_form_questions_create_dryrun_test.go | questions[].visible_rule; dry-run | request body, visible_rule passthrough, and help guard covered |
| ✕ | base +form-questions-delete | shortcut | | none | form workflows not covered |
| ✕ | base +form-questions-list | shortcut | | none | form workflows not covered |
| ✓ | base +form-questions-update | shortcut | TestBaseFormQuestionsUpdateVisibleRuleDryRun | questions[].visible_rule | dry-run: request shape + visible_rule body passthrough |
| ✓ | base +form-submit | shortcut | base_form_submit_dryrun_test.go::TestBaseFormSubmitDryRun | `--share-token`; `--json`; dry-run only | submission request shape |
| ✕ | base +form-update | shortcut | | none | form workflows not covered |
| ✓ | base +record-batch-create | shortcut | base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.create_records` | seeds heterogeneous live workflow records |
| ✓ | base +record-batch-update | shortcut | base_record_batch_update_dryrun_test.go::TestBaseRecordBatchUpdatePerRecordDryRun; base_record_batch_update_workflow_test.go::TestBaseRecordBatchUpdatePerRecordWorkflow | `--base-token`; `--table-id`; `--json.update_records`; dry-run + live | heterogeneous select/number update with write-back verification |
@@ -64,6 +71,7 @@
| ✕ | base +record-history-list | shortcut | | none | record workflows not covered |
| ✕ | base +record-list | shortcut | | none | record workflows not covered |
| ✕ | base +record-search | shortcut | | none | record workflows not covered |
| ✕ | base +record-share-link-create | shortcut | | none | record workflows not covered |
| ✓ | base +record-upload-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/upload | dry-run only | request shape only |
| ✓ | base +record-download-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/download | dry-run only | request shape only |
| ✓ | base +record-remove-attachment | shortcut | base_attachment_dryrun_test.go::TestBase_AttachmentDryRun/remove | dry-run only | request shape only |
@@ -78,6 +86,8 @@
| ✓ | base +table-get | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/get table as bot | `--base-token`; `--table-id` | |
| ✓ | base +table-list | shortcut | base_basic_workflow_test.go::TestBase_BasicWorkflow/list tables and find created table as bot | `--base-token` | |
| ✕ | base +table-update | shortcut | | none | no rename workflow yet |
| ✕ | base +title-resolve | shortcut | | none | resolver workflow not covered |
| ✕ | base +url-resolve | shortcut | | none | resolver workflow not covered |
| ✕ | base +view-create | shortcut | | none | view workflows not covered |
| ✕ | base +view-delete | shortcut | | none | view workflows not covered |
| ✕ | base +view-get | shortcut | | none | view workflows not covered |

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestWhiteboardNodeCreateDryRun_RequestShape(t *testing.T) {
setWhiteboardDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+node-create",
"--whiteboard-token", "wbcnCreateDryRun",
"--source", `{"nodes":[{"id":"tmpNode","type":"composite_shape","x":0,"y":0,"width":260,"height":45,"text":{"text":"hello","font_weight":"regular","font_size":14,"horizontal_align":"center","vertical_align":"mid"},"style":{"border_color":"#3370ff","border_width":"narrow","border_style":"solid","fill_color":"#e8f3ff"},"composite_shape":{"type":"round_rect"}}]}`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, int64(1), clie2e.DryRunGet(out, "api.#").Int(), out)
require.Equal(t, "POST", clie2e.DryRunGet(out, "api.0.method").String(), out)
gotURL := clie2e.DryRunGet(out, "api.0.url").String()
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") || !strings.HasSuffix(gotURL, "/nodes") || strings.Contains(gotURL, "wbcnCreateDryRun") {
t.Fatalf("url=%q, want masked board whiteboard nodes URL\nstdout:\n%s", gotURL, out)
}
require.Equal(t, "composite_shape", clie2e.DryRunGet(out, "api.0.body.nodes.0.type").String(), out)
require.Equal(t, "round_rect", clie2e.DryRunGet(out, "api.0.body.nodes.0.composite_shape.type").String(), out)
}

View File

@@ -0,0 +1,47 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestWhiteboardNodeDeleteDryRun_RequestShape(t *testing.T) {
setWhiteboardDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+node-delete",
"--whiteboard-token", "wbcnDeleteDryRun",
"--node-ids", "nodeA,nodeB",
"--idempotent-token", "delete-token-12345",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, int64(1), clie2e.DryRunGet(out, "api.#").Int(), out)
require.Equal(t, "DELETE", clie2e.DryRunGet(out, "api.0.method").String(), out)
gotURL := clie2e.DryRunGet(out, "api.0.url").String()
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") ||
!strings.HasSuffix(gotURL, "/nodes/batch_delete") ||
strings.Contains(gotURL, "wbcnDeleteDryRun") {
t.Fatalf("url=%q, want masked board whiteboard batch delete URL\nstdout:\n%s", gotURL, out)
}
require.Equal(t, "delete-token-12345", clie2e.DryRunGet(out, "api.0.params.client_token").String(), out)
require.Equal(t, "nodeA", clie2e.DryRunGet(out, "api.0.body.ids.0").String(), out)
require.Equal(t, "nodeB", clie2e.DryRunGet(out, "api.0.body.ids.1").String(), out)
}

View File

@@ -0,0 +1,48 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package whiteboard
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestWhiteboardNodeUpdateDryRun_RequestShape(t *testing.T) {
setWhiteboardDryRunEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"whiteboard", "+node-update",
"--whiteboard-token", "wbcnUpdateDryRun",
"--source", `{"nodes":[{"id":"nodeA","type":"text","text":{"content":"hello A"}},{"id":"nodeB","type":"text","text":{"content":"hello B"}}]}`,
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
require.Equal(t, int64(2), clie2e.DryRunGet(out, "api.#").Int(), out)
for i, nodeID := range []string{"nodeA", "nodeB"} {
require.Equal(t, "PUT", clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".method").String(), out)
gotURL := clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".url").String()
if !strings.HasPrefix(gotURL, "/open-apis/board/v1/whiteboards/") ||
!strings.HasSuffix(gotURL, "/nodes/"+nodeID) ||
strings.Contains(gotURL, "wbcnUpdateDryRun") {
t.Fatalf("url=%q, want masked board whiteboard node update URL ending with %s\nstdout:\n%s", gotURL, nodeID, out)
}
require.False(t, clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".body.node.id").Exists(), out)
require.Equal(t, "text", clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".body.node.type").String(), out)
require.Equal(t, "hello "+string(rune('A'+i)), clie2e.DryRunGet(out, "api."+string(rune('0'+i))+".body.node.text.content").String(), out)
}
}