diff --git a/shortcuts/drive/drive_member_list.go b/shortcuts/drive/drive_member_list.go new file mode 100644 index 0000000000..85c9e74a26 --- /dev/null +++ b/shortcuts/drive/drive_member_list.go @@ -0,0 +1,325 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "io" + "net/url" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +type driveMemberListSpec struct { + Token string + Type string + Fields string + PermType string +} + +var driveMemberListTypes = []string{ + "doc", "sheet", "file", "wiki", "bitable", "docx", + "mindnote", "minutes", "slides", "folder", +} + +var driveMemberListFields = []string{"name", "type", "avatar", "external_label"} +var driveMemberListPermTypes = []string{"container", "single_page"} + +var driveMemberListURLPathToType = []struct { + Prefix string + Type string +}{ + {"/drive/folder/", "folder"}, + {"/docx/", "docx"}, + {"/doc/", "doc"}, + {"/sheets/", "sheet"}, + {"/base/", "bitable"}, + {"/bitable/", "bitable"}, + {"/wiki/", "wiki"}, + {"/file/", "file"}, + {"/mindnotes/", "mindnote"}, + {"/slides/", "slides"}, + {"/minutes/", "minutes"}, +} + +func readDriveMemberListSpec(runtime *common.RuntimeContext) (driveMemberListSpec, error) { + token, resourceType, err := resolveDriveMemberListTarget(runtime.Str("token"), runtime.Str("type")) + if err != nil { + return driveMemberListSpec{}, err + } + fields, err := normalizeDriveMemberListFields(runtime.Str("fields"), runtime.Changed("fields")) + if err != nil { + return driveMemberListSpec{}, err + } + permType, err := normalizeDriveMemberListPermType(runtime.Str("perm-type"), resourceType, runtime.Changed("perm-type")) + if err != nil { + return driveMemberListSpec{}, err + } + return driveMemberListSpec{ + Token: token, + Type: resourceType, + Fields: fields, + PermType: permType, + }, nil +} + +func resolveDriveMemberListTarget(raw, explicitType string) (token, resourceType string, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token is required").WithParam("--token") + } + + explicitType, err = normalizeDriveMemberListEnumValue(explicitType, driveMemberListTypes, "--type") + if err != nil { + return "", "", err + } + + if strings.Contains(raw, "://") { + parsed, parseErr := url.Parse(raw) + if parseErr != nil || parsed.Hostname() == "" { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--token URL is malformed: %q", raw).WithParam("--token") + } + ref, ok := parseDriveMemberListResourceURLPath(parsed.Path) + if !ok { + return "", "", errs.NewValidationError( + errs.SubtypeInvalidArgument, + "unsupported --token URL %q: pass a recognized Lark Drive document/folder URL or a bare token with --type", + raw, + ).WithParam("--token") + } + if explicitType != "" && explicitType != ref.Type { + return "", "", errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--type %q conflicts with URL path type %q; remove --type or use a matching value", + explicitType, + ref.Type, + ).WithParam("--type") + } + if err := validate.ResourceName(ref.Token, "--token"); err != nil { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token") + } + return ref.Token, ref.Type, nil + } + + if explicitType == "" { + return "", "", errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--type is required when --token is a bare token; accepted values: %s", + strings.Join(driveMemberListTypes, ", "), + ).WithParam("--type") + } + if err := validate.ResourceName(raw, "--token"); err != nil { + return "", "", errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--token") + } + return raw, explicitType, nil +} + +func parseDriveMemberListResourceURLPath(path string) (common.ResourceRef, bool) { + for _, mapping := range driveMemberListURLPathToType { + if !strings.HasPrefix(path, mapping.Prefix) { + continue + } + token := path[len(mapping.Prefix):] + token = strings.TrimRight(token, "/") + if idx := strings.IndexByte(token, '/'); idx >= 0 { + token = token[:idx] + } + token = strings.TrimSpace(token) + if token == "" { + return common.ResourceRef{}, false + } + return common.ResourceRef{Type: mapping.Type, Token: token}, true + } + return common.ResourceRef{}, false +} + +func normalizeDriveMemberListFields(raw string, changed bool) (string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + if changed { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields cannot be blank; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields") + } + return "", nil + } + + parts := strings.Split(raw, ",") + fields := make([]string, 0, len(parts)) + seen := make(map[string]bool, len(parts)) + for _, part := range parts { + field := strings.ToLower(strings.TrimSpace(part)) + if field == "" { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields contains an empty field; allowed: %s, *", strings.Join(driveMemberListFields, ", ")).WithParam("--fields") + } + if field == "*" { + if len(parts) != 1 { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--fields=* cannot be combined with other fields").WithParam("--fields") + } + return "*", nil + } + if !driveMemberListFieldAllowed(field) { + return "", errs.NewValidationError( + errs.SubtypeInvalidArgument, + "invalid value %q for --fields, allowed: %s, *", + strings.TrimSpace(part), + strings.Join(driveMemberListFields, ", "), + ).WithParam("--fields") + } + if !seen[field] { + fields = append(fields, field) + seen[field] = true + } + } + return strings.Join(fields, ","), nil +} + +func driveMemberListFieldAllowed(field string) bool { + for _, allowed := range driveMemberListFields { + if field == allowed { + return true + } + } + return false +} + +func normalizeDriveMemberListPermType(raw, resourceType string, changed bool) (string, error) { + permType, err := normalizeDriveMemberListEnumValue(raw, driveMemberListPermTypes, "--perm-type") + if err != nil { + return "", err + } + if resourceType != "wiki" && changed { + return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "--perm-type only applies when resource type is wiki; got %q", resourceType).WithParam("--perm-type") + } + return permType, nil +} + +func normalizeDriveMemberListEnumValue(raw string, allowed []string, flagName string) (string, error) { + value := strings.TrimSpace(raw) + if value == "" { + return "", nil + } + for _, candidate := range allowed { + if strings.EqualFold(value, candidate) { + return candidate, nil + } + } + return "", errs.NewValidationError( + errs.SubtypeInvalidArgument, + "invalid value %q for %s, allowed: %s", + value, + flagName, + strings.Join(allowed, ", "), + ).WithParam(flagName) +} + +func (s driveMemberListSpec) apiPath() string { + return fmt.Sprintf("/open-apis/drive/v1/permissions/%s/members", validate.EncodePathSegment(s.Token)) +} + +func (s driveMemberListSpec) params() map[string]interface{} { + params := map[string]interface{}{"type": s.Type} + if s.Fields != "" { + params["fields"] = s.Fields + } + if s.PermType != "" { + params["perm_type"] = s.PermType + } + return params +} + +// DriveMemberList lists collaborator/member permissions on a Drive resource. +var DriveMemberList = common.Shortcut{ + Service: "drive", + Command: "+member-list", + Description: "List collaborator/member permissions on a Drive document, file, folder, or wiki node", + Risk: "read", + Scopes: []string{"docs:permission.member:retrieve"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "token", Desc: "target URL or bare token (doc/sheet/file/wiki/bitable/docx/mindnote/minutes/slides/folder)", Required: true}, + {Name: "type", Desc: "target type; auto-inferred from URL, required for bare tokens"}, + {Name: "fields", Desc: "optional collaborator fields to return: name,type,avatar,external_label or *"}, + {Name: "perm-type", Desc: "wiki permission scope filter; one of container|single_page"}, + }, + Tips: []string{ + "--token accepts a Lark URL or bare token; pass --type when using a bare token.", + "Use --type folder for Drive folders.", + "--fields is omitted by default; pass --fields '*' or a comma-separated subset when extra collaborator fields are needed.", + "--perm-type only applies to wiki nodes.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := readDriveMemberListSpec(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec, err := readDriveMemberListSpec(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return common.NewDryRunAPI(). + Desc("List Drive collaborator/member permissions"). + GET(spec.apiPath()). + Params(spec.params()) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec, err := readDriveMemberListSpec(runtime) + if err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive members for %s %s...\n", spec.Type, common.MaskToken(spec.Token)) + data, err := runtime.CallAPITyped("GET", spec.apiPath(), spec.params(), nil) + if err != nil { + return err + } + if items, ok := data["items"].([]interface{}); ok { + fmt.Fprintf(runtime.IO().ErrOut, "Found %d Drive member(s)\n", len(items)) + } + runtime.OutFormat(data, nil, func(w io.Writer) { + renderDriveMemberListPretty(w, data) + }) + return nil + }, +} + +func renderDriveMemberListPretty(w io.Writer, data map[string]interface{}) { + items, _ := data["items"].([]interface{}) + if len(items) == 0 { + fmt.Fprintln(w, "No Drive members found.") + return + } + for i, raw := range items { + member, _ := raw.(map[string]interface{}) + fmt.Fprintf(w, "[%d] %s\n", i+1, driveMemberListValue(member["member_id"])) + fmt.Fprintf(w, " member_type: %s\n", driveMemberListValue(member["member_type"])) + fmt.Fprintf(w, " perm: %s\n", driveMemberListValue(member["perm"])) + if permType := driveMemberListValue(member["perm_type"]); permType != "-" { + fmt.Fprintf(w, " perm_type: %s\n", permType) + } + if memberType := driveMemberListValue(member["type"]); memberType != "-" { + fmt.Fprintf(w, " type: %s\n", memberType) + } + if name := driveMemberListValue(member["name"]); name != "-" { + fmt.Fprintf(w, " name: %s\n", name) + } + if avatar := driveMemberListValue(member["avatar"]); avatar != "-" { + fmt.Fprintf(w, " avatar: %s\n", avatar) + } + if label, ok := member["external_label"]; ok { + fmt.Fprintf(w, " external_label: %v\n", label) + } + fmt.Fprintln(w) + } +} + +func driveMemberListValue(v interface{}) string { + if s, ok := v.(string); ok && s != "" { + return s + } + return "-" +} diff --git a/shortcuts/drive/drive_member_list_test.go b/shortcuts/drive/drive_member_list_test.go new file mode 100644 index 0000000000..0beb91a7d5 --- /dev/null +++ b/shortcuts/drive/drive_member_list_test.go @@ -0,0 +1,426 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "encoding/json" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func newDriveMemberListRuntime(t *testing.T, token, docType, fields, permType string) *common.RuntimeContext { + t.Helper() + + cmd := &cobra.Command{Use: "drive +member-list"} + cmd.Flags().String("token", "", "") + cmd.Flags().String("type", "", "") + cmd.Flags().String("fields", "", "") + cmd.Flags().String("perm-type", "", "") + for name, value := range map[string]string{ + "token": token, + "type": docType, + "fields": fields, + "perm-type": permType, + } { + if value == "" { + continue + } + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + return common.TestNewRuntimeContext(cmd, driveTestConfig()) +} + +func TestDriveMemberListSpecResolvesTargets(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + docType string + wantTok string + wantType string + }{ + { + name: "folder URL", + token: "https://example.feishu.cn/drive/folder/fldTok?from=share", + wantTok: "fldTok", + wantType: "folder", + }, + { + name: "docx URL", + token: "https://example.feishu.cn/docx/doxTok", + wantTok: "doxTok", + wantType: "docx", + }, + { + name: "bare folder token", + token: " fldTok ", + docType: " folder ", + wantTok: "fldTok", + wantType: "folder", + }, + { + name: "mindnotes URL", + token: "https://example.feishu.cn/mindnotes/mndTok", + wantTok: "mndTok", + wantType: "mindnote", + }, + { + name: "minutes URL", + token: "https://example.feishu.cn/minutes/obTok", + wantTok: "obTok", + wantType: "minutes", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, "", "") + spec, err := readDriveMemberListSpec(runtime) + if err != nil { + t.Fatalf("read spec: %v", err) + } + if spec.Token != tt.wantTok || spec.Type != tt.wantType { + t.Fatalf("spec token/type = %q/%q, want %q/%q", spec.Token, spec.Type, tt.wantTok, tt.wantType) + } + }) + } +} + +func TestDriveMemberListSpecValidationErrorsAreTyped(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + docType string + fields string + permType string + wantParam string + wantMessage string + }{ + { + name: "missing token", + wantParam: "--token", + wantMessage: "--token is required", + }, + { + name: "bare token without type", + token: "doxTok", + wantParam: "--type", + wantMessage: "--type is required", + }, + { + name: "unsupported URL", + token: "https://example.feishu.cn/calendar/calTok", + wantParam: "--token", + wantMessage: "unsupported --token URL", + }, + { + name: "URL type conflict", + token: "https://example.feishu.cn/docx/doxTok", + docType: "folder", + wantParam: "--type", + wantMessage: "conflicts with URL path type", + }, + { + name: "invalid bare token", + token: "../bad", + docType: "folder", + wantParam: "--token", + wantMessage: "--token", + }, + { + name: "invalid type", + token: "doxTok", + docType: "comment", + wantParam: "--type", + wantMessage: "invalid value", + }, + { + name: "invalid fields", + token: "doxTok", + docType: "docx", + fields: "name,unknown", + wantParam: "--fields", + wantMessage: "invalid value", + }, + { + name: "star mixed with fields", + token: "doxTok", + docType: "docx", + fields: "*,name", + wantParam: "--fields", + wantMessage: "cannot be combined", + }, + { + name: "perm type rejected for non-wiki", + token: "doxTok", + docType: "docx", + permType: "single_page", + wantParam: "--perm-type", + wantMessage: "only applies when resource type is wiki", + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType) + _, err := readDriveMemberListSpec(runtime) + if err == nil { + t.Fatal("expected validation error, got nil") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error is not typed: %T %v", err, err) + } + if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype) + } + validationErr, ok := err.(*errs.ValidationError) + if !ok { + t.Fatalf("error type = %T, want *errs.ValidationError", err) + } + if validationErr.Param != tt.wantParam { + t.Fatalf("param = %q, want %q", validationErr.Param, tt.wantParam) + } + if !strings.Contains(err.Error(), tt.wantMessage) { + t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantMessage) + } + }) + } +} + +func TestDriveMemberListSpecParams(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + token string + docType string + fields string + permType string + want map[string]interface{} + }{ + { + name: "default omits optional params", + token: "doxTok", + docType: "docx", + want: map[string]interface{}{"type": "docx"}, + }, + { + name: "fields canonicalized and deduplicated", + token: "doxTok", + docType: "docx", + fields: "Name,avatar,name", + want: map[string]interface{}{"type": "docx", "fields": "name,avatar"}, + }, + { + name: "wiki accepts perm type", + token: "wikTok", + docType: "WIKI", + fields: "*", + permType: "SINGLE_PAGE", + want: map[string]interface{}{"type": "wiki", "fields": "*", "perm_type": "single_page"}, + }, + } + + for _, temp := range tests { + tt := temp + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + runtime := newDriveMemberListRuntime(t, tt.token, tt.docType, tt.fields, tt.permType) + spec, err := readDriveMemberListSpec(runtime) + if err != nil { + t.Fatalf("read spec: %v", err) + } + if got := spec.params(); !reflect.DeepEqual(got, tt.want) { + t.Fatalf("params = %#v, want %#v", got, tt.want) + } + }) + } +} + +func TestDriveMemberListDryRunIncludesGETRequest(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + err := mountAndRunDrive(t, DriveMemberList, []string{ + "+member-list", + "--token", "https://example.feishu.cn/drive/folder/fldTok", + "--fields", "*", + "--dry-run", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var got struct { + Data struct { + API []struct { + Method string `json:"method"` + URL string `json:"url"` + Params map[string]interface{} `json:"params"` + } `json:"api"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &got); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, stdout.String()) + } + if len(got.Data.API) != 1 { + t.Fatalf("api count = %d, want 1", len(got.Data.API)) + } + api := got.Data.API[0] + if api.Method != "GET" || api.URL != "/open-apis/drive/v1/permissions/fldTok/members" { + t.Fatalf("api = %#v", api) + } + if api.Params["type"] != "folder" || api.Params["fields"] != "*" { + t.Fatalf("params = %#v", api.Params) + } + if _, ok := api.Params["perm_type"]; ok { + t.Fatalf("perm_type should be omitted for folder: %#v", api.Params) + } +} + +func TestDriveMemberListExecutePreservesRawData(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, stderr, reg := cmdutil.TestFactory(t, driveTestConfig()) + + var capturedQuery string + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/permissions/doxTok/members", + OnMatch: func(req *http.Request) { + capturedQuery = req.URL.RawQuery + }, + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "member_id": "ou_x", + "member_type": "openid", + "perm": "view", + "type": "user", + "name": "zhangsan", + "server_future": "preserved", + "external_label": true, + }, + }, + "server_top_level": "preserved", + }, + }, + }) + + err := mountAndRunDrive(t, DriveMemberList, []string{ + "+member-list", + "--token", "doxTok", + "--type", "docx", + "--fields", "name,type,external_label", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(capturedQuery, "type=docx") || + !strings.Contains(capturedQuery, "fields=name%2Ctype%2Cexternal_label") { + t.Fatalf("captured query = %q", capturedQuery) + } + data := decodeDriveEnvelope(t, stdout) + if data["server_top_level"] != "preserved" { + t.Fatalf("server_top_level = %#v", data["server_top_level"]) + } + for _, key := range []string{"token", "type", "count"} { + if _, ok := data[key]; ok { + t.Fatalf("data[%s] = %#v, want omitted", key, data[key]) + } + } + items, _ := data["items"].([]interface{}) + if len(items) != 1 { + t.Fatalf("items = %#v, want one item", data["items"]) + } + item, _ := items[0].(map[string]interface{}) + if item["server_future"] != "preserved" || item["external_label"] != true { + t.Fatalf("item future fields not preserved: %#v", item) + } + if !strings.Contains(stderr.String(), "Found 1 Drive member") { + t.Fatalf("stderr = %q, want count log", stderr.String()) + } +} + +func TestDriveMemberListDeclaresScopeAndIdentities(t *testing.T) { + t.Parallel() + + if !reflect.DeepEqual(DriveMemberList.Scopes, []string{"docs:permission.member:retrieve"}) { + t.Fatalf("Scopes = %v, want docs:permission.member:retrieve", DriveMemberList.Scopes) + } + if !reflect.DeepEqual(DriveMemberList.AuthTypes, []string{"user", "bot"}) { + t.Fatalf("AuthTypes = %v, want [user bot]", DriveMemberList.AuthTypes) + } +} + +func TestDriveMemberListPrettyOutput(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/permissions/wikTok/members", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "items": []interface{}{ + map[string]interface{}{ + "member_id": "ou_x", + "member_type": "openid", + "perm": "view", + "perm_type": "single_page", + "type": "user", + "name": "zhangsan", + }, + }, + }, + }, + }) + + err := mountAndRunDrive(t, DriveMemberList, []string{ + "+member-list", + "--token", "wikTok", + "--type", "wiki", + "--perm-type", "single_page", + "--format", "pretty", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + out := stdout.String() + for _, want := range []string{"[1] ou_x", "member_type: openid", "perm_type: single_page", "name: zhangsan"} { + if !strings.Contains(out, want) { + t.Fatalf("pretty output missing %q:\n%s", want, out) + } + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index d274674c94..a61a3f459c 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -32,6 +32,7 @@ func Shortcuts() []common.Shortcut { DriveTaskResult, DriveApplyPermission, DriveMemberAdd, + DriveMemberList, DriveSecureLabelList, DriveSecureLabelUpdate, DriveSearch, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 5f5071d1a6..2c38cb3807 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -39,6 +39,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+task_result", "+apply-permission", "+member-add", + "+member-list", "+secure-label-list", "+secure-label-update", "+search", diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 2be992fa18..43153a8656 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -163,6 +163,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+inspect`](references/lark-drive-inspect.md) | 检视 URL 的类型、标题和 canonical token;wiki URL 会自动解包到底层文档。 | | [`+apply-permission`](references/lark-drive-apply-permission.md) | 以 user 身份向文档 owner 申请访问权限。 | | [`+member-add`](references/lark-drive-member-add.md) | 添加一个或最多 10 个 Drive 文档、文件、文件夹或 wiki 节点协作者/授权成员;封装 Drive permission member create/batch_create,真实写入需要 `--yes`。 | +| [`+member-list`](references/lark-drive-member-list.md) | 查询 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。 | | [`+secure-label-list`](references/lark-drive-secure-label.md) | 列出当前用户可用的密级标签。 | | [`+secure-label-update`](references/lark-drive-secure-label.md) | 更新 Drive 文件或文档的密级标签。 | diff --git a/skills/lark-drive/references/lark-drive-member-list.md b/skills/lark-drive/references/lark-drive-member-list.md new file mode 100644 index 0000000000..592e80155d --- /dev/null +++ b/skills/lark-drive/references/lark-drive-member-list.md @@ -0,0 +1,65 @@ +# drive +member-list(查询协作者/授权成员列表) + +本 skill 对应 shortcut:`lark-cli drive +member-list`。它读取 Drive 文档、文件、文件夹或 wiki 节点的协作者/授权成员列表。 + +## 命令 + +```bash +# URL 自动推断 type +lark-cli drive +member-list \ + --token 'https://example.feishu.cn/drive/folder/' \ + --as user --format json + +# 查询附加字段 +lark-cli drive +member-list \ + --token '' \ + --type docx \ + --fields 'name,type,external_label' \ + --as user --format json + +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--token` | 是 | 裸 token 或完整 URL。URL 路径支持 `/folder/`、`/docx/`、`/doc/`、`/sheets/`、`/base/`、`/bitable/`、`/wiki/`、`/file/`、`/mindnotes/`、`/slides/`、`/minutes/`。 | +| `--type` | 裸 token 必填 | 目标类型:`doc` / `sheet` / `file` / `wiki` / `bitable` / `docx` / `mindnote` / `minutes` / `slides` / `folder`。URL 可自动推断;如果同时传 URL 和冲突的 `--type`,CLI 会拒绝。 | +| `--fields` | 否 | 默认不传。可取 `name` / `type` / `avatar` / `external_label`,支持逗号分隔;也可传 `*` 请求当前支持的所有附加字段。该参数只声明期望返回的字段,不授予字段级权限。 | +| `--perm-type` | 否 | 仅 `--type wiki` 有效;取值 `container` / `single_page`。 | +| `--dry-run` | 否 | 只打印请求,不调用 API。 | + +## 输出 + +JSON 输出原样透传 API 的 `data` : + +```json +{ + "ok": true, + "identity": "user", + "data": { + "items": [ + { + "member_type": "openid", + "member_id": "ou_xxx", + "perm": "view", + "perm_type": "container", + "type": "user", + "name": "zhangsan", + "external_label": false + } + ] + } +} +``` + +`--format pretty` 会轻量展示成员 ID、成员类型、权限、wiki `perm_type` 和已返回的附加字段。机器读取优先使用 `--format json`。 + +## 行为说明 + +- **身份支持**:`--as user` 和 `--as bot` 均可用;缺 scope 或目标权限时按统一 permission 错误路径处理。 +- **接口 scope**:查询成员列表需要 `docs:permission.member:retrieve`。 +- **fields 默认**:不传 `--fields` 时按官方 API 默认,不请求姓名、头像、外部标签等附加字段;需要时显式指定。 +- **字段级权限**:`--fields` 只控制请求哪些附加字段,不保证服务端一定返回。请求用户的 `name` / `avatar` 时,应用还需开通 `contact:user.base:readonly`(“获取用户基本信息”;已具备官方兼容的历史通讯录权限也可满足要求)。 +- **缺字段语义**:字段级权限或数据可见性不足时,接口仍可能成功,但会省略相应敏感字段。响应中缺少已请求字段表示“服务端未返回”,不能解释为字段值为空,也不能据此认定成员信息完整。 +- **folder 支持**:CLI 支持 `--type folder` 并会按需求发送 `type=folder`;部分环境的后端如果尚未放开 folder 枚举,可能返回 `99992402 field validation failed`。 diff --git a/skills/lark-drive/references/lark-drive-secure-label.md b/skills/lark-drive/references/lark-drive-secure-label.md index a8790d16c6..b713e5c521 100644 --- a/skills/lark-drive/references/lark-drive-secure-label.md +++ b/skills/lark-drive/references/lark-drive-secure-label.md @@ -28,7 +28,7 @@ lark-cli drive +secure-label-list --page-size 10 --lang zh ```bash lark-cli drive +secure-label-update \ --token "https://example.feishu.cn/docx/doxcnxxxx" \ - --label-id "7217780879644737539" + --label-id '' # replace $LABEL_ID before running ``` 参数: diff --git a/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md b/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md index b9caec04ad..231e1ae170 100644 --- a/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md +++ b/skills/lark-drive/references/lark-drive-workflow-permission-governance-commands.md @@ -25,16 +25,16 @@ lark-cli drive +inspect --url '' --as user --format json lark-cli wiki +node-list \ --space-id '' --page-size 50 \ --page-all --page-limit 0 \ - --as user --format json + --as user --format json # replace $SPACE_ID before running lark-cli wiki +node-list \ --space-id '' --parent-node-token '' --page-size 50 \ --page-all --page-limit 0 \ - --as user --format json + --as user --format json # replace $SPACE_ID before running lark-cli wiki +node-list \ --space-id '' --page-token '' --page-size 50 \ - --as user --format json + --as user --format json # replace $SPACE_ID before running ``` 解析返回时使用 `data.nodes`,不要读取顶层 `items`。`--page-limit 0` 表示当前层分页不设页数上限;`--page-all` 只覆盖当前 `space-id` / `parent-node-token` 范围内的分页,不会递归子节点。节点 `has_child=true` 时,必须继续以该节点的 `node_token` 作为 `--parent-node-token` 递归读取。 @@ -69,6 +69,18 @@ lark-cli drive permission.public get \ --as user --format json ``` +按需读取直接协作者/授权成员列表: + +```bash +lark-cli drive +member-list \ + --token '' \ + --type '' \ + --fields 'name,type,external_label' \ + --as user --format json +``` + +`--fields` 默认不传;只有需要名称、协作者类型、头像或外部标签时才显式传。它只声明期望返回的字段,不授予字段级权限:请求用户的 `name` / `avatar` 时还需 `contact:user.base:readonly`(“获取用户基本信息”)。字段权限或数据可见性不足时,接口仍可能成功但省略相应字段;缺字段不能解释为空值。 + 按需读取访问统计: ```bash @@ -160,9 +172,9 @@ lark-cli drive +secure-label-list \ ```bash lark-cli drive +secure-label-update \ --token '' \ - --label-id '' --as user --format json + --label-id '' --as user --format json # replace $LABEL_ID before running lark-cli drive +secure-label-update \ --token '' --type '' \ - --label-id '' --as user --format json + --label-id '' --as user --format json # replace $LABEL_ID before running ``` diff --git a/skills/lark-drive/references/lark-drive-workflow-permission-governance.md b/skills/lark-drive/references/lark-drive-workflow-permission-governance.md index 82fd11760f..50d3fdf47d 100644 --- a/skills/lark-drive/references/lark-drive-workflow-permission-governance.md +++ b/skills/lark-drive/references/lark-drive-workflow-permission-governance.md @@ -42,7 +42,7 @@ Risk / Structure: `R2` / `S2` - 当前身份无法枚举到的不可见文档的完整发现;只能处理已发现目标,或用户显式提供的 URL / token。 - 未按范围确认的批量写入。 -不要声称已完成协作者列表验证:当前 CLI surface 没有 `permission.members list` shortcut。 +协作者列表读取只覆盖当前目标的直接协作者/授权成员:可使用 `drive +member-list` 。 ## Progressive Load Map @@ -96,6 +96,7 @@ Risk / Structure: `R2` / `S2` | `DISCOVER_TARGETS` | `drive files list` | 递归发现 Drive folder 下当前身份可见的文件和子文件夹 | | `FACT_READ` | `drive metas batch_query` | 读取 title、URL、owner 和 secure-label metadata | | `FACT_READ` | `drive permission.public get` | 读取支持类型的文档公共访问和协作权限设置,包括链接分享、对外分享、协作者管理、复制内容、创建副本、打印、下载和评论 | +| `FACT_READ` | `drive +member-list` | 读取用户显式要求的单目标直接协作者/授权成员列表;不代表完整继承链或历史权限审计 | | `FACT_READ` | `drive file.statistics get` | 在用户要求活跃度、闲置暴露、生命周期或访问复核时读取文件访问统计 | | `FACT_READ` | `drive file.view_records list` | 在用户要求最近访问人、访问复核或低活跃证据时读取访问记录 | | `EXEC_CONFIRM` | `drive +secure-label-list` | 提议 label update 前解析可用 secure-label IDs | @@ -194,7 +195,7 @@ Drive folder 发现: - `drive permission.members create` 可创建协作者权限,但当前 workflow 不做协作者 grant / update / revoke;未来需要单独定义授权对象解析、最小权限、确认模板和验证方式。 - backup owner、部门 / 项目负责人绑定没有当前 workflow 可执行写入面;如用户要落地为 owner 转移,必须先给出明确目标和新 owner,并走本 workflow 的 owner-transfer 确认。 - `wiki +member-list` 可作为 Wiki space 成员治理的读侧事实来源;当前 workflow 只治理文档 / 节点 / 文件夹下可发现文档的权限,不做 space member governance。 -- 当前 CLI 没有 `permission.members list`、完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。 +- `drive +member-list` 可读取单目标直接协作者/授权成员;当前 CLI 仍没有完整继承链、DLP 扫描、AI 索引状态、审计日志和跨平台权限事实。遇到这些需求必须记录为 `unsupported_checks` 或建议新增独立 workflow。 ## 输出策略 diff --git a/tests/cli_e2e/drive/drive_member_list_test.go b/tests/cli_e2e/drive/drive_member_list_test.go new file mode 100644 index 0000000000..9f1b8575ad --- /dev/null +++ b/tests/cli_e2e/drive/drive_member_list_test.go @@ -0,0 +1,165 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDrive_MemberListDryRun(t *testing.T) { + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "app") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") + + tests := []struct { + name string + args []string + wantURL string + wantType string + wantFields string + wantPermType string + }{ + { + name: "bare folder token", + args: []string{ + "drive", "+member-list", + "--token", "fldE2E001", + "--type", "folder", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/fldE2E001/members", + wantType: "folder", + }, + { + name: "folder URL infers folder type", + args: []string{ + "drive", "+member-list", + "--token", "https://example.feishu.cn/drive/folder/fldE2E002?from=share", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/fldE2E002/members", + wantType: "folder", + }, + { + name: "fields star is passed only when explicit", + args: []string{ + "drive", "+member-list", + "--token", "doxE2E003", + "--type", "docx", + "--fields", "*", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/doxE2E003/members", + wantType: "docx", + wantFields: "*", + }, + { + name: "wiki perm type", + args: []string{ + "drive", "+member-list", + "--token", "wikE2E004", + "--type", "wiki", + "--fields", "name,type", + "--perm-type", "single_page", + "--dry-run", + }, + wantURL: "/open-apis/drive/v1/permissions/wikE2E004/members", + wantType: "wiki", + wantFields: "name,type", + wantPermType: "single_page", + }, + } + + for _, temp := range tests { + tt := temp + 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: tt.args, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := gjson.Get(out, "data.api.0.method").String(); got != "GET" { + t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out) + } + if got := gjson.Get(out, "data.api.0.url").String(); got != tt.wantURL { + t.Fatalf("url = %q, want %q\nstdout:\n%s", got, tt.wantURL, out) + } + if got := gjson.Get(out, "data.api.0.params.type").String(); got != tt.wantType { + t.Fatalf("params.type = %q, want %q\nstdout:\n%s", got, tt.wantType, out) + } + if tt.wantFields == "" { + if gjson.Get(out, "data.api.0.params.fields").Exists() { + t.Fatalf("params.fields should be omitted\nstdout:\n%s", out) + } + } else if got := gjson.Get(out, "data.api.0.params.fields").String(); got != tt.wantFields { + t.Fatalf("params.fields = %q, want %q\nstdout:\n%s", got, tt.wantFields, out) + } + if tt.wantPermType == "" { + if gjson.Get(out, "data.api.0.params.perm_type").Exists() { + t.Fatalf("params.perm_type should be omitted\nstdout:\n%s", out) + } + } else if got := gjson.Get(out, "data.api.0.params.perm_type").String(); got != tt.wantPermType { + t.Fatalf("params.perm_type = %q, want %q\nstdout:\n%s", got, tt.wantPermType, out) + } + }) + } +} + +func TestDrive_MemberListWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + folderName := "lark-cli-e2e-drive-member-list-" + clie2e.GenerateSuffix() + folderToken := createDriveFolderOrSkipPermission(t, parentT, ctx, folderName) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+member-list", + "--token", folderToken, + "--type", "folder", + "--format", "json", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode != 0 { + combinedOutput := strings.ToLower(result.Stdout + "\n" + result.Stderr) + if strings.Contains(combinedOutput, "docs:permission.member:retrieve") || + strings.Contains(combinedOutput, "app scope not enabled") || + strings.Contains(combinedOutput, "missing required scope") || + strings.Contains(combinedOutput, "missing_scope") || + strings.Contains(combinedOutput, "99991672") || + strings.Contains(combinedOutput, "1063002") || + strings.Contains(combinedOutput, "1063004") || + strings.Contains(combinedOutput, "permission denied") || + strings.Contains(combinedOutput, "no share permission") { + t.Skipf("skip drive member list workflow due to missing bot scope or folder permission: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr)) + } + if strings.Contains(combinedOutput, "99992402") && + strings.Contains(combinedOutput, "field validation failed") { + t.Skipf("skip drive member list workflow because this environment does not yet accept type=folder on the member list API: %s", strings.TrimSpace(result.Stdout+"\n"+result.Stderr)) + } + t.Fatalf("drive member list workflow failed: exit=%d\nstdout:\n%s\nstderr:\n%s", result.ExitCode, result.Stdout, result.Stderr) + } + result.AssertStdoutStatus(t, true) + + if items := gjson.Get(result.Stdout, "data.items"); !items.Exists() || !items.IsArray() { + t.Fatalf("data.items must be present as an array\nstdout:\n%s", result.Stdout) + } +}