diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index b8c31eac84..b2bd3f2665 100644 --- a/shortcuts/base/base_shortcuts_test.go +++ b/shortcuts/base/base_shortcuts_test.go @@ -172,8 +172,8 @@ func TestShortcutsCatalog(t *testing.T) { "+data-query", "+form-create", "+form-delete", "+form-list", "+form-update", "+form-get", "+form-detail", "+form-questions-create", "+form-questions-delete", "+form-questions-update", "+form-questions-list", - "+form-submit", - "+dashboard-list", "+dashboard-get", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange", + "+form-submit", "+form-share-get", "+form-share-update", + "+dashboard-list", "+dashboard-get", "+dashboard-share-get", "+dashboard-share-update", "+dashboard-create", "+dashboard-update", "+dashboard-delete", "+dashboard-arrange", "+dashboard-block-list", "+dashboard-block-get", "+dashboard-block-get-data", "+dashboard-block-create", "+dashboard-block-update", "+dashboard-block-delete", "+workspace-create", "+workspace-entity-list", "+workspace-move-in", "+app-create", "+app-get", diff --git a/shortcuts/base/dashboard_share.go b/shortcuts/base/dashboard_share.go new file mode 100644 index 0000000000..b8f9f6ccda --- /dev/null +++ b/shortcuts/base/dashboard_share.go @@ -0,0 +1,104 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var dashboardShareUpdateFlagNames = []string{ + "enabled", + "access-scope", + "show-source", + "enable-auto-analysis", +} + +var BaseDashboardShareGet = common.Shortcut{ + Service: "base", + Command: "+dashboard-share-get", + Description: "Get dashboard share status and settings", + Risk: "read", + Scopes: []string{"base:dashboard:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + }, + DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/share"). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")) + }, + Execute: func(_ context.Context, runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "GET", baseV3Path( + "bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "share", + ), nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var BaseDashboardShareUpdate = common.Shortcut{ + Service: "base", + Command: "+dashboard-share-update", + Description: "Update dashboard share status and settings", + Risk: "write", + Scopes: []string{"base:dashboard:update"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + dashboardIDFlag(true), + {Name: "enabled", Type: "bool", Desc: "enable or disable dashboard sharing"}, + {Name: "access-scope", Desc: "share access scope", Enum: shareAccessScopeEnums}, + {Name: "show-source", Type: "bool", Desc: "show the entry back to the source Base"}, + {Name: "enable-auto-analysis", Type: "bool", Desc: "enable intelligent analysis on the shared dashboard"}, + }, + Tips: []string{ + "Boolean settings use PATCH semantics: pass --show-source=false or --enable-auto-analysis=false to explicitly turn them off.", + "Update exactly one field per invocation; run separate commands to change multiple share fields.", + }, + Validate: func(_ context.Context, runtime *common.RuntimeContext) error { + return validateSingleShareUpdate(runtime, dashboardShareUpdateFlagNames...) + }, + DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/dashboards/:dashboard_id/share"). + Body(buildDashboardShareUpdateBody(runtime)). + Set("base_token", runtime.Str("base-token")). + Set("dashboard_id", runtime.Str("dashboard-id")) + }, + Execute: func(_ context.Context, runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "PATCH", baseV3Path( + "bases", runtime.Str("base-token"), "dashboards", runtime.Str("dashboard-id"), "share", + ), nil, buildDashboardShareUpdateBody(runtime)) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +func buildDashboardShareUpdateBody(runtime *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{} + addCommonShareUpdateFields(runtime, body) + + settings := map[string]interface{}{} + if runtime.Changed("show-source") { + settings["show_source"] = runtime.Bool("show-source") + } + if runtime.Changed("enable-auto-analysis") { + settings["enable_auto_analysis"] = runtime.Bool("enable-auto-analysis") + } + if len(settings) > 0 { + body["settings"] = settings + } + return body +} diff --git a/shortcuts/base/form_share.go b/shortcuts/base/form_share.go new file mode 100644 index 0000000000..d0ae5d4fd2 --- /dev/null +++ b/shortcuts/base/form_share.go @@ -0,0 +1,114 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + + "github.com/larksuite/cli/shortcuts/common" +) + +var formShareUpdateFlagNames = []string{ + "enabled", + "access-scope", + "allow-anonymous", + "require-login", +} + +var BaseFormShareGet = common.Shortcut{ + Service: "base", + Command: "+form-share-get", + Description: "Get form share status and settings", + Risk: "read", + Scopes: []string{"base:form:read"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + }, + DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/share"). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(_ context.Context, runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "GET", baseV3Path( + "bases", runtime.Str("base-token"), "tables", runtime.Str("table-id"), "forms", runtime.Str("form-id"), "share", + ), nil, nil) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +var BaseFormShareUpdate = common.Shortcut{ + Service: "base", + Command: "+form-share-update", + Description: "Update form share status and settings", + Risk: "write", + Scopes: []string{"base:form:update"}, + AuthTypes: authTypes(), + Flags: []common.Flag{ + baseTokenFlag(true), + {Name: "table-id", Desc: "table ID", Required: true}, + {Name: "form-id", Desc: "form ID", Required: true}, + {Name: "enabled", Type: "bool", Desc: "enable or disable form sharing"}, + {Name: "access-scope", Desc: "share access scope", Enum: shareAccessScopeEnums}, + {Name: "allow-anonymous", Type: "bool", Desc: "anonymize the submitter identity"}, + {Name: "require-login", Type: "bool", Desc: "require submitters to sign in before submitting"}, + }, + Tips: []string{ + "Boolean settings use PATCH semantics: pass --allow-anonymous=false or another boolean flag with =false to explicitly turn it off.", + "Using --allow-anonymous=true with --require-login=true requires sign-in but anonymizes the submitted identity.", + "Update exactly one field per invocation; run separate commands to change multiple share fields.", + }, + Validate: func(_ context.Context, runtime *common.RuntimeContext) error { + return validateFormShareUpdate(runtime) + }, + DryRun: func(_ context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return common.NewDryRunAPI(). + PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/forms/:form_id/share"). + Body(buildFormShareUpdateBody(runtime)). + Set("base_token", runtime.Str("base-token")). + Set("table_id", runtime.Str("table-id")). + Set("form_id", runtime.Str("form-id")) + }, + Execute: func(_ context.Context, runtime *common.RuntimeContext) error { + data, err := baseV3Call(runtime, "PATCH", baseV3Path( + "bases", runtime.Str("base-token"), "tables", runtime.Str("table-id"), "forms", runtime.Str("form-id"), "share", + ), nil, buildFormShareUpdateBody(runtime)) + if err != nil { + return err + } + runtime.Out(data, nil) + return nil + }, +} + +func validateFormShareUpdate(runtime *common.RuntimeContext) error { + return validateSingleShareUpdate(runtime, formShareUpdateFlagNames...) +} + +func buildFormShareUpdateBody(runtime *common.RuntimeContext) map[string]interface{} { + body := map[string]interface{}{} + addCommonShareUpdateFields(runtime, body) + + settings := map[string]interface{}{} + if runtime.Changed("allow-anonymous") { + settings["allow_anonymous"] = runtime.Bool("allow-anonymous") + } + if runtime.Changed("require-login") { + settings["require_login"] = runtime.Bool("require-login") + } + + if len(settings) > 0 { + body["settings"] = settings + } + return body +} diff --git a/shortcuts/base/share_common.go b/shortcuts/base/share_common.go new file mode 100644 index 0000000000..ace03add80 --- /dev/null +++ b/shortcuts/base/share_common.go @@ -0,0 +1,48 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/shortcuts/common" +) + +var shareAccessScopeEnums = []string{"invite", "tenant", "anyone"} + +func validateSingleShareUpdate(runtime *common.RuntimeContext, flagNames ...string) error { + changedNames := make([]string, 0, len(flagNames)) + for _, name := range flagNames { + if runtime.Changed(name) { + changedNames = append(changedNames, name) + } + } + switch len(changedNames) { + case 1: + return nil + case 0: + return errs.NewValidationError(errs.SubtypeInvalidArgument, "exactly one share field must be provided"). + WithHint("use one of: %s", shareFlagNames(flagNames)) + default: + return baseFlagErrorf("share update accepts exactly one field; do not combine %s", shareFlagNames(changedNames)) + } +} + +func shareFlagNames(flagNames []string) string { + names := make([]string, 0, len(flagNames)) + for _, name := range flagNames { + names = append(names, "--"+name) + } + return strings.Join(names, ", ") +} + +func addCommonShareUpdateFields(runtime *common.RuntimeContext, body map[string]interface{}) { + if runtime.Changed("enabled") { + body["enabled"] = runtime.Bool("enabled") + } + if runtime.Changed("access-scope") { + body["access_scope"] = runtime.Str("access-scope") + } +} diff --git a/shortcuts/base/share_execute_test.go b/shortcuts/base/share_execute_test.go new file mode 100644 index 0000000000..4d6e81d15e --- /dev/null +++ b/shortcuts/base/share_execute_test.go @@ -0,0 +1,286 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "reflect" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestDashboardShareGetCallsResourceEndpoint(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/share", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "enabled": true, + "access_scope": "tenant", + }, + }, + }) + + err := runShortcut(t, BaseDashboardShareGet, []string{ + "+dashboard-share-get", + "--base-token", "app_x", + "--dashboard-id", "dsh_1", + }, factory, stdout) + if err != nil { + t.Fatalf("run shortcut: %v", err) + } + if got := stdout.String(); !strings.Contains(got, `"access_scope": "tenant"`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestDashboardShareUpdatePreservesExplicitFalse(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/share", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"enabled": true}, + }, + } + reg.Register(stub) + + err := runShortcut(t, BaseDashboardShareUpdate, []string{ + "+dashboard-share-update", + "--base-token", "app_x", + "--dashboard-id", "dsh_1", + "--show-source=false", + "--enable-auto-analysis=true", + }, factory, stdout) + if err != nil { + t.Fatalf("run shortcut: %v", err) + } + + want := map[string]interface{}{ + "settings": map[string]interface{}{ + "show_source": false, + "enable_auto_analysis": true, + }, + } + if got := decodeCapturedJSONBody(t, stub); !reflect.DeepEqual(got, want) { + t.Fatalf("request body=%#v, want %#v", got, want) + } +} + +func TestDashboardShareUpdateBuildsCommonFields(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/share", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"enabled": true}, + }, + } + reg.Register(stub) + + err := runShortcut(t, BaseDashboardShareUpdate, []string{ + "+dashboard-share-update", + "--base-token", "app_x", + "--dashboard-id", "dsh_1", + "--enabled=true", + "--access-scope", "invite", + }, factory, stdout) + if err != nil { + t.Fatalf("run shortcut: %v", err) + } + + want := map[string]interface{}{ + "enabled": true, + "access_scope": "invite", + } + if got := decodeCapturedJSONBody(t, stub); !reflect.DeepEqual(got, want) { + t.Fatalf("request body=%#v, want %#v", got, want) + } +} + +func TestDashboardShareUpdatePreservesExplicitFalseForEnabled(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/share", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"enabled": false}, + }, + } + reg.Register(stub) + + err := runShortcut(t, BaseDashboardShareUpdate, []string{ + "+dashboard-share-update", + "--base-token", "app_x", + "--dashboard-id", "dsh_1", + "--enabled=false", + }, factory, stdout) + if err != nil { + t.Fatalf("run shortcut: %v", err) + } + + want := map[string]interface{}{"enabled": false} + if got := decodeCapturedJSONBody(t, stub); !reflect.DeepEqual(got, want) { + t.Fatalf("request body=%#v, want %#v", got, want) + } +} + +func TestFormShareGetCallsResourceEndpoint(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_1/forms/vew_1/share", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "enabled": false, + "access_scope": "tenant", + }, + }, + }) + + err := runShortcut(t, BaseFormShareGet, []string{ + "+form-share-get", + "--base-token", "app_x", + "--table-id", "tbl_1", + "--form-id", "vew_1", + }, factory, stdout) + if err != nil { + t.Fatalf("run shortcut: %v", err) + } + if got := stdout.String(); !strings.Contains(got, `"enabled": false`) { + t.Fatalf("stdout=%s", got) + } +} + +func TestFormShareUpdateBuildsAccessScope(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_1/forms/vew_1/share", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"enabled": true}, + }, + } + reg.Register(stub) + + err := runShortcut(t, BaseFormShareUpdate, []string{ + "+form-share-update", + "--base-token", "app_x", + "--table-id", "tbl_1", + "--form-id", "vew_1", + "--access-scope", "anyone", + }, factory, stdout) + if err != nil { + t.Fatalf("run shortcut: %v", err) + } + + want := map[string]interface{}{"access_scope": "anyone"} + if got := decodeCapturedJSONBody(t, stub); !reflect.DeepEqual(got, want) { + t.Fatalf("request body=%#v, want %#v", got, want) + } +} + +func TestFormShareUpdatePreservesExplicitFalseForEnabled(t *testing.T) { + factory, stdout, reg := newExecuteFactory(t) + stub := &httpmock.Stub{ + Method: "PATCH", + URL: "/open-apis/base/v3/bases/app_x/tables/tbl_1/forms/vew_1/share", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{"enabled": false}, + }, + } + reg.Register(stub) + + err := runShortcut(t, BaseFormShareUpdate, []string{ + "+form-share-update", + "--base-token", "app_x", + "--table-id", "tbl_1", + "--form-id", "vew_1", + "--enabled=false", + }, factory, stdout) + if err != nil { + t.Fatalf("run shortcut: %v", err) + } + + want := map[string]interface{}{"enabled": false} + if got := decodeCapturedJSONBody(t, stub); !reflect.DeepEqual(got, want) { + t.Fatalf("request body=%#v, want %#v", got, want) + } +} + +func TestDashboardShareUpdateRejectsMultipleFields(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseDashboardShareUpdate, []string{ + "+dashboard-share-update", + "--base-token", "app_x", + "--dashboard-id", "dsh_1", + "--enabled=false", + "--access-scope", "tenant", + "--show-source=true", + }, factory, stdout) + + assertInvalidArgumentValidation(t, err, "--enabled", []string{"--enabled", "--access-scope", "--show-source"}, "exactly one") +} + +func TestFormShareUpdateRejectsMultipleSettings(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseFormShareUpdate, []string{ + "+form-share-update", + "--base-token", "app_x", + "--table-id", "tbl_1", + "--form-id", "vew_1", + "--allow-anonymous=true", + "--require-login=true", + }, factory, stdout) + + assertInvalidArgumentValidation(t, err, "--allow-anonymous", []string{"--allow-anonymous", "--require-login"}, "exactly one") +} + +func TestShareUpdateRejectsUnsupportedAccessScope(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseDashboardShareUpdate, []string{ + "+dashboard-share-update", + "--base-token", "app_x", + "--dashboard-id", "dsh_1", + "--access-scope", "off", + }, factory, stdout) + + assertInvalidArgumentValidation(t, err, "--access-scope", nil, "allowed") +} + +func TestShareUpdateRequiresAtLeastOneChange(t *testing.T) { + factory, stdout, _ := newExecuteFactory(t) + err := runShortcut(t, BaseFormShareUpdate, []string{ + "+form-share-update", + "--base-token", "app_x", + "--table-id", "tbl_1", + "--form-id", "vew_1", + }, factory, stdout) + + assertInvalidArgumentValidation(t, err, "", []string{}, "at least one") + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T %v", err, err) + } + for _, flag := range []string{ + "--enabled", + "--access-scope", + "--allow-anonymous", + "--require-login", + } { + if !strings.Contains(problem.Hint, flag) { + t.Fatalf("hint=%q, want flag %q", problem.Hint, flag) + } + } +} diff --git a/shortcuts/base/shortcuts.go b/shortcuts/base/shortcuts.go index d6ddc9df46..2edab79cd7 100644 --- a/shortcuts/base/shortcuts.go +++ b/shortcuts/base/shortcuts.go @@ -85,8 +85,12 @@ func Shortcuts() []common.Shortcut { BaseFormQuestionsUpdate, BaseFormQuestionsList, BaseFormSubmit, + BaseFormShareGet, + BaseFormShareUpdate, BaseDashboardList, BaseDashboardGet, + BaseDashboardShareGet, + BaseDashboardShareUpdate, BaseDashboardCreate, BaseDashboardUpdate, BaseDashboardDelete, diff --git a/skills/lark-base/SKILL.md b/skills/lark-base/SKILL.md index 5f257b1a70..49b4a172aa 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -1,6 +1,6 @@ --- name: lark-base -version: 1.2.6 +version: 1.2.8 description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、应用模式(BaseApp/AppMode 页面与组件)、Workspace 目录、workflow、角色权限;遇到 Base/多维表格/bitable、BaseApp/AppMode,或应用模式的 /app/ 链接(可能同时包含 /base/workspace/)时使用。BaseApp 不走 lark-apps;文件导入/导出转 lark-drive,认证/授权转 lark-shared。" metadata: requires: @@ -83,8 +83,9 @@ metadata: | 表单提交 | `+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` | 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-share-get` / `+form-share-update` | 更新前先 get;每次只传一个修改字段,多个字段拆成多次命令;布尔 flag 显式传 `true` / `false` | | 分享表单详情 | `+form-detail --share-token ` | 使用表单分享链接里的 `share_token`;提交前读 [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`;读取完整仪表盘时按 block 类型分流,文本和不支持直接取数的图表按 reference 恢复 | +| 仪表盘、分享与组件 | `+dashboard-*` / `+dashboard-share-*` / `+dashboard-block-*` | 分享设置更新前先 get,每次只修改一个分享字段;图表/看板/block 读 [lark-base-dashboard.md](references/lark-base-dashboard.md),组件 `data_config` 读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md) | | 查询 BaseApp 与关联 Base | `+url-resolve` → `+app-get` → `+base-get` | 只把 `/app/` URL 传给 `+url-resolve`,不要把 `/base/workspace/` URL 传给它;用 `+app-get ref` 的 key 作为 `base_token` 再调用 `+base-get`。最终答复忠实保留应用 `name` / `app_token`,以及每个关联 Base 的 `name` / `base_token` | | 管理应用模式(BaseApp/AppMode)页面与组件 | `+app-page-*` / `+app-block-*` | BaseApp/AppMode、Workspace 内应用或带 base/workspace 上下文的 `/app/` 链接直接走本路由,不走 `lark-apps`;没有 `+app-list`,列 Workspace 内应用必须用 `+workspace-entity-list --workspace-token --type baseapp`;先读 [lark-base-app.md](references/lark-base-app.md)。组件 `data_config` 读 [lark-base-app-block-data-config.md](references/lark-base-app-block-data-config.md);`+app-block-get-data` 除 `app_token` 外还需要图表数据源的 `base_token` | | 复制 Page / 设置页面图标 | 当前不支持 | 不产生任何写入,不得用 `+app-page-create` 冒充完整复制;单独说明“可新建空 Page”仅是替代能力,须等用户明确要求后再执行 | @@ -128,6 +129,7 @@ metadata: ## 表单与视图细节 - Base 内表单 list/get/create/update/delete 和题目管理都属于具体数据表:第一个管理命令前必须已有归属明确的真实 `table_id`;缺失或归属不明确时才用 `+table-list` 或 `+base-block-list` 定位,已有真实 ID 时直接复用。后续管理命令始终传同一 `base_token + table_id`。 +- 表单分享使用 `+form-share-get/update` 管理启停、访问范围和匿名/登录要求。`--access-scope` 支持 `invite` / `tenant` / `anyone`;`invite` 仅允许受邀者访问,不等价于关闭分享页。`--allow-anonymous` 控制提交者身份是否匿名化,`--require-login` 控制提交前是否必须登录;两者同时为 `true` 表示“登录后匿名提交”,但需要分两次命令设置。每次 update 只允许传一个修改字段;布尔值必须显式传 `true` 或 `false`。填写有效期、个人提交频率、总回收上限、提交后修改和通知配置不在当前命令范围内。 - 表单问题由数据表字段承载,question `id` 就是 `field_id`。创建问题前先 `+form-questions-list`;除非用户明确要求同名的独立问题,否则标题已存在时优先用 `+form-questions-update` 修改必填状态、标题或描述,不要先创建同名问题再删除旧问题。 - `+form-questions-delete` 用于删除非主字段问题;主字段问题使用 `+form-questions-update` 修改。 - `+form-submit` 是高风险写操作,必须带 `--yes` 确认;调用前必须先跑 `+form-detail`,读取 `questions[].type`、`required`、`filter` 和附件场景需要的 `base_token`;不要填写被 filter 隐藏的问题。 @@ -139,6 +141,7 @@ metadata: ## Dashboard / Workflow / Role - Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,仅在用户明确要求重排/美化、或对本次会话从零新建的仪表盘做收尾整理时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`。用户要求“全部/完整”仪表盘内容时不得跳过 text 或不支持直接取数的 block,按 [lark-base-dashboard.md](references/lark-base-dashboard.md) 的完整读取分支恢复。 +- 仪表盘分享使用 `+dashboard-share-get/update` 管理启停、访问范围、返回源 Base 入口和智能分析。`--access-scope` 支持 `invite` / `tenant` / `anyone`;`invite` 仅允许受邀者访问,不等价于关闭分享页。每次 update 只允许传一个修改字段,多个字段拆成多次命令;显式 `false` 会被保留。 - Dashboard shortcut 不支持指定组件的 `x/y/w/h`、精确位置或尺寸,不能把 `+dashboard-arrange` 静默当作等价实现。用户只要求一般性重排/美化时可执行一次智能重排;用户要求精确结果时先说明限制并询问是否接受自适应布局,接受后才执行。不要探测 raw `lark-cli api`、源码或未公开布局参数。 - 创建接口成功返回即表示写入成功;只有结果不确定时才额外执行一次 `+dashboard-get` 或 `+dashboard-block-list`。不要仅为确认创建而逐组件调用 `+dashboard-block-get-data`。 - 用户要读取多个组件的计算结果时,先完整列出组件(`+dashboard-block-list --page-size 100`;若 `has_more=true`,继续把返回的 `page_token` 传给 `--page-token`,直到 `has_more=false`),再按 [lark-base-dashboard-block-get-data.md](references/lark-base-dashboard-block-get-data.md) 在一个 shell 工具调用内串行读取;不要把每个 block 拆成独立模型轮次。 diff --git a/tests/cli_e2e/base/base_share_dryrun_test.go b/tests/cli_e2e/base/base_share_dryrun_test.go new file mode 100644 index 0000000000..fe59d355bc --- /dev/null +++ b/tests/cli_e2e/base/base_share_dryrun_test.go @@ -0,0 +1,68 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestBaseShareDryRun(t *testing.T) { + t.Run("dashboard get", func(t *testing.T) { + result := runBaseDryRun(t, 0, + "base", "+dashboard-share-get", + "--base-token", "app_x", + "--dashboard-id", "dsh_1", + ) + assert.Contains(t, result.Stdout, `"method": "GET"`) + assert.Contains(t, result.Stdout, "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/share") + }) + + t.Run("dashboard partial update", func(t *testing.T) { + result := runBaseDryRun(t, 0, + "base", "+dashboard-share-update", + "--base-token", "app_x", + "--dashboard-id", "dsh_1", + "--show-source=false", + ) + assert.Contains(t, result.Stdout, `"method": "PATCH"`) + assert.Contains(t, result.Stdout, `"show_source": false`) + }) + + t.Run("form get", func(t *testing.T) { + result := runBaseDryRun(t, 0, + "base", "+form-share-get", + "--base-token", "app_x", + "--table-id", "tbl_1", + "--form-id", "vew_1", + ) + assert.Contains(t, result.Stdout, `"method": "GET"`) + assert.Contains(t, result.Stdout, "/open-apis/base/v3/bases/app_x/tables/tbl_1/forms/vew_1/share") + }) + + t.Run("form settings update", func(t *testing.T) { + result := runBaseDryRun(t, 0, + "base", "+form-share-update", + "--base-token", "app_x", + "--table-id", "tbl_1", + "--form-id", "vew_1", + "--allow-anonymous=true", + ) + assert.Contains(t, result.Stdout, `"method": "PATCH"`) + assert.Contains(t, result.Stdout, `"allow_anonymous": true`) + }) + + t.Run("form submission policy is not exposed", func(t *testing.T) { + result := runBaseDryRun(t, 2, + "base", "+form-share-update", + "--base-token", "app_x", + "--table-id", "tbl_1", + "--form-id", "vew_1", + "--valid-period-enabled=true", + ) + assert.Contains(t, result.Stderr, "unknown flag") + assert.Contains(t, result.Stderr, "--valid-period-enabled") + }) +} diff --git a/tests/cli_e2e/base/base_share_workflow_test.go b/tests/cli_e2e/base/base_share_workflow_test.go new file mode 100644 index 0000000000..4c74d8d164 --- /dev/null +++ b/tests/cli_e2e/base/base_share_workflow_test.go @@ -0,0 +1,152 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package base + +import ( + "context" + "os" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestBaseShareWorkflow(t *testing.T) { + if os.Getenv("LARK_CLI_E2E_BASE_SHARE_READY") != "1" { + t.Skip("set LARK_CLI_E2E_BASE_SHARE_READY=1 after the dashboard/form share OpenAPI is deployed") + } + clie2e.SkipWithoutTenantAccessToken(t) + + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute) + t.Cleanup(cancel) + + baseToken := createBaseWithRetry(t, ctx, "lark-cli-e2e-base-share-"+clie2e.GenerateSuffix()) + tableID, _, _ := createTableWithRetry( + t, + parentT, + ctx, + baseToken, + "Share workflow "+clie2e.GenerateSuffix(), + `[{"name":"Name","type":"text"}]`, + `{"name":"Main","type":"grid"}`, + ) + + formCreate, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+form-create", + "--base-token", baseToken, + "--table-id", tableID, + "--name", "Share form " + clie2e.GenerateSuffix(), + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + formCreate.AssertExitCode(t, 0) + formCreate.AssertStdoutStatus(t, true) + formID := gjson.Get(formCreate.Stdout, "data.id").String() + require.NotEmpty(t, formID, formCreate.Stdout) + + dashboardCreate, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "base", "+dashboard-create", + "--base-token", baseToken, + "--name", "Share dashboard " + clie2e.GenerateSuffix(), + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + dashboardCreate.AssertExitCode(t, 0) + dashboardCreate.AssertStdoutStatus(t, true) + dashboardID := gjson.Get(dashboardCreate.Stdout, "data.dashboard.dashboard_id").String() + require.NotEmpty(t, dashboardID, dashboardCreate.Stdout) + + t.Cleanup(func() { + cleanupCtx, cleanupCancel := cleanupContext() + defer cleanupCancel() + for _, args := range [][]string{ + {"base", "+dashboard-share-update", "--base-token", baseToken, "--dashboard-id", dashboardID, "--enabled=false"}, + {"base", "+form-share-update", "--base-token", baseToken, "--table-id", tableID, "--form-id", formID, "--enabled=false"}, + } { + result, cleanupErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{Args: args, DefaultAs: "bot"}) + if cleanupErr != nil || result == nil || result.ExitCode != 0 { + reportCleanupFailure(parentT, "disable share", result, cleanupErr) + } + } + }) + + t.Run("dashboard share update and get", func(t *testing.T) { + runUpdate := func(fieldArgs ...string) { + args := append([]string{ + "base", "+dashboard-share-update", + "--base-token", baseToken, + "--dashboard-id", dashboardID, + }, fieldArgs...) + update, runErr := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"}) + require.NoError(t, runErr) + update.AssertExitCode(t, 0) + update.AssertStdoutStatus(t, true) + } + runUpdate("--enabled=true") + runUpdate("--access-scope", "invite") + runUpdate("--show-source=true") + runUpdate("--enable-auto-analysis=true") + runUpdate("--show-source=false") + runUpdate("--enable-auto-analysis=false") + + get, runErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+dashboard-share-get", "--base-token", baseToken, "--dashboard-id", dashboardID}, + DefaultAs: "bot", + }) + require.NoError(t, runErr) + get.AssertExitCode(t, 0) + get.AssertStdoutStatus(t, true) + require.True(t, gjson.Get(get.Stdout, "data.enabled").Bool(), get.Stdout) + require.Equal(t, "invite", gjson.Get(get.Stdout, "data.access_scope").String(), get.Stdout) + showSource := gjson.Get(get.Stdout, "data.settings.show_source") + require.True(t, showSource.Exists(), get.Stdout) + require.False(t, showSource.Bool(), get.Stdout) + autoAnalysis := gjson.Get(get.Stdout, "data.settings.enable_auto_analysis") + require.True(t, autoAnalysis.Exists(), get.Stdout) + require.False(t, autoAnalysis.Bool(), get.Stdout) + }) + + t.Run("form share update and get", func(t *testing.T) { + runUpdate := func(fieldArgs ...string) { + args := append([]string{ + "base", "+form-share-update", + "--base-token", baseToken, + "--table-id", tableID, + "--form-id", formID, + }, fieldArgs...) + update, runErr := clie2e.RunCmd(ctx, clie2e.Request{Args: args, DefaultAs: "bot"}) + require.NoError(t, runErr) + update.AssertExitCode(t, 0) + update.AssertStdoutStatus(t, true) + } + runUpdate("--enabled=true") + runUpdate("--access-scope", "invite") + runUpdate("--allow-anonymous=true") + runUpdate("--require-login=true") + runUpdate("--allow-anonymous=false") + + get, runErr := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{"base", "+form-share-get", "--base-token", baseToken, "--table-id", tableID, "--form-id", formID}, + DefaultAs: "bot", + }) + require.NoError(t, runErr) + get.AssertExitCode(t, 0) + get.AssertStdoutStatus(t, true) + require.True(t, gjson.Get(get.Stdout, "data.enabled").Bool(), get.Stdout) + require.Equal(t, "invite", gjson.Get(get.Stdout, "data.access_scope").String(), get.Stdout) + allowAnonymous := gjson.Get(get.Stdout, "data.settings.allow_anonymous") + require.True(t, allowAnonymous.Exists(), get.Stdout) + require.False(t, allowAnonymous.Bool(), get.Stdout) + requireLogin := gjson.Get(get.Stdout, "data.settings.require_login") + require.True(t, requireLogin.Exists(), get.Stdout) + require.True(t, requireLogin.Bool(), get.Stdout) + }) +} diff --git a/tests/cli_e2e/base/coverage.md b/tests/cli_e2e/base/coverage.md index a6020ef2ec..3521e71bcb 100644 --- a/tests/cli_e2e/base/coverage.md +++ b/tests/cli_e2e/base/coverage.md @@ -1,9 +1,9 @@ # Base CLI E2E Coverage ## Metrics -- Denominator: 89 leaf commands -- Covered: 30 -- Coverage: 33.7% +- Denominator: 93 leaf commands +- Covered: 34 +- Coverage: 36.6% ## 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`. @@ -13,6 +13,8 @@ - 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. +- TestBaseShareDryRun: proves dashboard/form share GET and PATCH routes, one-field update requests, explicit false preservation, and nested form settings without touching live data. +- TestBaseShareWorkflow: deployment-gated by `LARK_CLI_E2E_BASE_SHARE_READY=1`; creates a Base, table, form, and dashboard, updates each share field in a separate request, verifies get round trips for both resources, disables sharing, and cleans up the Base. - 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`. @@ -48,6 +50,8 @@ | ✕ | base +dashboard-delete | shortcut | | none | dashboard workflows not covered | | ✕ | base +dashboard-get | shortcut | | none | dashboard workflows not covered | | ✕ | base +dashboard-list | shortcut | | none | dashboard workflows not covered | +| ✓ | base +dashboard-share-get | shortcut | base_share_dryrun_test.go::TestBaseShareDryRun/dashboard get; base_share_workflow_test.go::TestBaseShareWorkflow/dashboard share update and get | `--base-token`; `--dashboard-id`; dry-run + deployment-gated live | live requires `LARK_CLI_E2E_BASE_SHARE_READY=1` | +| ✓ | base +dashboard-share-update | shortcut | base_share_dryrun_test.go::TestBaseShareDryRun/dashboard partial update; base_share_workflow_test.go::TestBaseShareWorkflow/dashboard share update and get | one of `--enabled`; `--access-scope=invite`; `--show-source`; `--enable-auto-analysis` per request | single-field updates, explicit false, invite-only scope, and live read-back covered | | ✕ | base +dashboard-update | shortcut | | none | dashboard workflows not covered | | ✕ | base +data-query | shortcut | | none | no data-query assertions yet | | ✓ | base +field-create | shortcut | base_field_dryrun_test.go::TestBaseFieldCreateDryRunArrayCompat | `--base-token`; `--table-id`; `--json`; dry-run only | request shape only | @@ -61,6 +65,8 @@ | ✓ | 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 | base_form_detail_dryrun_test.go::TestBaseFormListDryRun_UsesBaseAndTableIdentifiers | `--base-token`; `--table-id`; dry-run only | request shape only | +| ✓ | base +form-share-get | shortcut | base_share_dryrun_test.go::TestBaseShareDryRun/form get; base_share_workflow_test.go::TestBaseShareWorkflow/form share update and get | `--base-token`; `--table-id`; `--form-id`; dry-run + deployment-gated live | live requires `LARK_CLI_E2E_BASE_SHARE_READY=1` | +| ✓ | base +form-share-update | shortcut | base_share_dryrun_test.go::TestBaseShareDryRun/form settings update; base_share_workflow_test.go::TestBaseShareWorkflow/form share update and get | one of share enablement; `access-scope=invite`; anonymous/login settings per request | single-field updates, login-plus-anonymous across separate requests, explicit false, and live read-back covered | | ✓ | 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 |