diff --git a/shortcuts/base/base_shortcuts_test.go b/shortcuts/base/base_shortcuts_test.go index d708a859..5104eabc 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", @@ -190,6 +190,43 @@ func TestShortcutsCatalog(t *testing.T) { } } +func TestShareManagementShortcutScopes(t *testing.T) { + tests := []struct { + name string + scopes []string + want []string + }{ + { + name: "dashboard get requires update scope", + scopes: BaseDashboardShareGet.Scopes, + want: []string{"base:dashboard:update"}, + }, + { + name: "dashboard update requires update scope", + scopes: BaseDashboardShareUpdate.Scopes, + want: []string{"base:dashboard:update"}, + }, + { + name: "form get requires update scope", + scopes: BaseFormShareGet.Scopes, + want: []string{"base:form:update"}, + }, + { + name: "form update requires update scope", + scopes: BaseFormShareUpdate.Scopes, + want: []string{"base:form:update"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if !reflect.DeepEqual(tt.scopes, tt.want) { + t.Fatalf("Scopes=%v want=%v", tt.scopes, tt.want) + } + }) + } +} + func TestShortcutsDryRunCoverage(t *testing.T) { for _, shortcut := range Shortcuts() { if shortcut.DryRun == nil { diff --git a/shortcuts/base/dashboard_share.go b/shortcuts/base/dashboard_share.go new file mode 100644 index 00000000..5df85953 --- /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:update"}, + 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 00000000..ab3f6da4 --- /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:update"}, + 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.", + "--allow-anonymous controls submitter identity and --require-login controls sign-in; run separate update commands to change both settings.", + "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 00000000..ace03add --- /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 00000000..927d2d60 --- /dev/null +++ b/shortcuts/base/share_execute_test.go @@ -0,0 +1,354 @@ +// 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) { + tests := []struct { + name string + flag string + want map[string]interface{} + }{ + { + name: "show source", + flag: "--show-source=false", + want: map[string]interface{}{"settings": map[string]interface{}{"show_source": false}}, + }, + { + name: "auto analysis", + flag: "--enable-auto-analysis=false", + want: map[string]interface{}{"settings": map[string]interface{}{"enable_auto_analysis": false}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(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", + tt.flag, + }, factory, stdout) + if err != nil { + t.Fatalf("run shortcut: %v", err) + } + if got := decodeCapturedJSONBody(t, stub); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("request body=%#v, want %#v", got, tt.want) + } + }) + } +} + +func TestDashboardShareUpdateBuildsCommonFields(t *testing.T) { + tests := []struct { + name string + flags []string + want map[string]interface{} + }{ + {name: "enabled", flags: []string{"--enabled=true"}, want: map[string]interface{}{"enabled": true}}, + {name: "access scope", flags: []string{"--access-scope", "invite"}, want: map[string]interface{}{"access_scope": "invite"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(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) + + args := []string{ + "+dashboard-share-update", + "--base-token", "app_x", + "--dashboard-id", "dsh_1", + } + args = append(args, tt.flags...) + if err := runShortcut(t, BaseDashboardShareUpdate, args, factory, stdout); err != nil { + t.Fatalf("run shortcut: %v", err) + } + if got := decodeCapturedJSONBody(t, stub); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("request body=%#v, want %#v", got, tt.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 TestFormShareUpdateBuildsSingleSettingsField(t *testing.T) { + tests := []struct { + name string + flag string + want map[string]interface{} + }{ + { + name: "allow anonymous false", + flag: "--allow-anonymous=false", + want: map[string]interface{}{"settings": map[string]interface{}{"allow_anonymous": false}}, + }, + { + name: "require login true", + flag: "--require-login=true", + want: map[string]interface{}{"settings": map[string]interface{}{"require_login": true}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(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", + tt.flag, + }, factory, stdout) + if err != nil { + t.Fatalf("run shortcut: %v", err) + } + if got := decodeCapturedJSONBody(t, stub); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("request body=%#v, want %#v", got, tt.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 TestShareUpdateRequiresExactlyOneChange(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{}, "exactly 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 d6ddc9df..2edab79c 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 0c34d3aa..c881e5ee 100644 --- a/skills/lark-base/SKILL.md +++ b/skills/lark-base/SKILL.md @@ -1,6 +1,6 @@ --- name: lark-base -version: 1.2.19 +version: 1.2.20 description: "飞书多维表格(Base)操作:建表、字段、记录、视图、统计、公式/lookup、表单、仪表盘、应用模式(BaseApp/AppMode 页面与组件)、Workspace 目录、workflow、角色权限;遇到 Base/多维表格/bitable、BaseApp/AppMode、/base/ 或 /app/ 链接时使用。BaseApp 不走 lark-apps;文件导入/导出转 lark-drive,认证/授权转 lark-shared。" metadata: requires: @@ -98,12 +98,15 @@ Form 依附于 Table,以 Field 作为题目,每次有效提交会创建一 1. **读取 Table 中的表单配置:** 使用 `+form-list` / `+form-get` 读取表单,使用 `+form-questions-list` 读取题目配置;这些命令使用表单所属的 `base_token + table_id`。 2. **创建或修改 Table 中的表单配置:** 使用 `+form-create` / `+form-update` / `+form-delete` 管理表单;题目由 Table Field 承载,question ID 对应 `field_id`,创建和更新分别读取 [questions create](references/lark-base-form-questions-create.md) / [questions update](references/lark-base-form-questions-update.md),删除使用 `+form-questions-delete`。 -3. **填写分享表单并提交:** 对表单分享链接使用 `+url-resolve` 取得 `share_token`,按 [Form detail](references/lark-base-form-detail.md) 执行 `+form-detail` 读取真实题目、必填项和显示条件,再按 [Form submit](references/lark-base-form-submit.md) 构造字段与附件并执行 `+form-submit`。 +3. **管理表单分享:** 使用 `+form-share-get` / `+form-share-update` 管理启停、访问范围和匿名/登录要求;更新前先读取现状,每次只修改一个字段,布尔值显式传 `true` 或 `false`。 +4. **填写分享表单并提交:** 对表单分享链接使用 `+url-resolve` 取得 `share_token`,按 [Form detail](references/lark-base-form-detail.md) 执行 `+form-detail` 读取真实题目、必填项和显示条件,再按 [Form submit](references/lark-base-form-submit.md) 构造字段与附件并执行 `+form-submit`。 ## Dashboard Block Dashboard Block 是 Base Block 树中的仪表盘容器,负责承载页面主题、布局和内部组件集合,本身不表示某一项图表数据。使用 `+dashboard-list` 定位容器,`+dashboard-get` 读取容器信息,`+dashboard-update` 修改主题,`+dashboard-arrange` 统一编排内部组件布局。 +**管理 Dashboard 分享:** 使用 `+dashboard-share-get` / `+dashboard-share-update` 管理启停、访问范围、返回源 Base 入口和智能分析;更新前先读取现状,每次只修改一个字段,显式 `false` 会被保留。 + 容器内部的图表、指标卡和文本等组件在 Dashboard API 中也称为 Block,但不属于 Base Block 树。内部 Block 分为三条操作路径: 1. **读取配置:** `+dashboard-block-list` / `+dashboard-block-get` 读取组件类型、布局和 `data_config`;文本组件的正文也属于配置。 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 00000000..16d7d244 --- /dev/null +++ b/tests/cli_e2e/base/base_share_dryrun_test.go @@ -0,0 +1,74 @@ +// 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, "/open-apis/base/v3/bases/app_x/dashboards/dsh_1/share") + assert.Contains(t, result.Stdout, `"show_source": false`) + assert.NotContains(t, result.Stdout, `"enabled":`) + assert.NotContains(t, result.Stdout, `"enable_auto_analysis":`) + }) + + 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, "/open-apis/base/v3/bases/app_x/tables/tbl_1/forms/vew_1/share") + assert.Contains(t, result.Stdout, `"allow_anonymous": true`) + assert.NotContains(t, result.Stdout, `"enabled":`) + assert.NotContains(t, result.Stdout, `"require_login":`) + }) + + 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 00000000..4c74d8d1 --- /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 6ae02a8c..ef242adb 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: 37 -- Coverage: 41.6% +- Denominator: 93 leaf commands +- Covered: 41 +- Coverage: 44.1% ## 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`. @@ -15,6 +15,8 @@ - TestBaseFormDetailDryRun / TestBaseFormSubmitDryRun: prove shared-form detail and submission request shapes. - TestBaseDashboardBlockGetDataDryRun: proves dashboard block data request shapes and identifier handling. - TestBaseDashboardBlockLayoutPrecisionWorkflow: creates a temporary Base/table/dashboard, creates a statistics block with `position` and omitted `number_format`, asserts the server default, updates to a custom format, then verifies a precision-only update preserves `formatName`, and cleans up the block/dashboard/base. `+dashboard-create`, `+dashboard-delete`, `+dashboard-block-get` and `+dashboard-block-delete` have no dry-run coverage and rest on this test alone. This workflow was executed successfully against a live tenant on 2026-08-20 while validating PR #2118. +- 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. - TestBaseRecordHistoryListDryRunUsesExplicitRecordID / TestBaseRecordHistoryListDryRunRejectsNonPositiveMaxVersion: prove the history request keeps the explicit record ID and rejects explicitly non-positive cursors with a typed validation error. @@ -51,6 +53,8 @@ | ✓ | base +dashboard-delete | shortcut | base_dashboard_block_layout_precision_workflow_test.go::TestBaseDashboardBlockLayoutPrecisionWorkflow (cleanup) | `--base-token`; `--dashboard-id`; `--yes`; live cleanup | deletes the temporary dashboard | | ✕ | 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 | @@ -64,6 +68,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 |