diff --git a/shortcuts/drive/drive_copy.go b/shortcuts/drive/drive_copy.go new file mode 100644 index 0000000000..19a38f6dd3 --- /dev/null +++ b/shortcuts/drive/drive_copy.go @@ -0,0 +1,358 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + driveCopyMaxNameBytes = 256 + // driveCopyMySpaceSentinel lets callers target the My Space root folder + // without knowing its token; Execute resolves it via the root-folder-meta + // endpoint (absent from platform metadata, path fixed per official docs). + driveCopyMySpaceSentinel = "my_space" + driveCopyRootFolderMetaPath = "/open-apis/drive/explorer/v2/root_folder/meta" +) + +var driveCopyTypes = []string{"doc", "docx", "sheet", "file", "mindnote", "slides", "bitable", "base", "wiki"} + +type driveCopyRef struct { + Token string + Type string + SourceFlag string +} + +type driveCopyExtra struct { + Key string + Value string +} + +type driveCopySpec struct { + Ref driveCopyRef + Name string + FolderToken string // empty when FolderMySpace is set + FolderMySpace bool + Extras []driveCopyExtra +} + +// DriveCopy copies a Drive file into a target folder through the Drive copy +// API, with URL parsing. Wiki inputs are rejected with a redirect to the +// existing `wiki +node-copy` shortcut. +var DriveCopy = common.Shortcut{ + Service: "drive", + Command: "+copy", + Description: "Copy a doc/docx/sheet/file/mindnote/slides/base(bitable) into a target folder, with URL parsing; wiki inputs are redirected to wiki +node-copy", + Risk: "write", + Scopes: []string{"docs:document:copy"}, + ConditionalScopes: []string{"drive:drive.metadata:readonly"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "url", Desc: "recommended: Lark/Feishu document URL (doc/docx/sheet/file/mindnote/slides/base/bitable)"}, + {Name: "token", Desc: "document token or document URL; bare tokens require --type"}, + {Name: "type", Desc: "document type for bare --token; optional for URLs but must match the URL type when provided", Enum: driveCopyTypes}, + {Name: "name", Desc: "name for the copied file, up to 256 bytes", Required: true}, + {Name: "folder-token", Desc: "target folder token, folder URL, or the constant my_space to copy into the caller's My Space root folder", Required: true}, + {Name: "extra", Type: "string_array", Desc: "repeatable key=value pair forwarded verbatim as a custom copy parameter, e.g. --extra target_type=docx to convert a legacy doc into a docx copy"}, + }, + Tips: []string{ + "The source type must match the real file type; the API rejects mismatches.", + "Use `--extra target_type=docx` with a legacy doc source to create the copy as a new-version docx.", + "`--folder-token my_space` resolves the caller's My Space root folder automatically; resolution needs the drive:drive.metadata:readonly (or drive:drive) scope.", + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + _, err := readDriveCopySpec(runtime) + return err + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec, err := readDriveCopySpec(runtime) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return buildDriveCopyDryRun(spec) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec, err := readDriveCopySpec(runtime) + if err != nil { + return err + } + + folderToken := spec.FolderToken + if spec.FolderMySpace { + folderToken, err = resolveDriveCopyMySpaceRoot(runtime) + if err != nil { + return err + } + } + + fmt.Fprintf(runtime.IO().ErrOut, "Copying %s %s to folder %s...\n", + spec.Ref.Type, common.MaskToken(spec.Ref.Token), common.MaskToken(folderToken)) + + data, err := runtime.CallAPITyped( + "POST", + fmt.Sprintf("/open-apis/drive/v1/files/%s/copy", validate.EncodePathSegment(spec.Ref.Token)), + nil, + buildDriveCopyBody(spec, folderToken), + ) + if err != nil { + return err + } + + runtime.Out(buildDriveCopyOutput(runtime, spec, folderToken, data), nil) + return nil + }, +} + +func readDriveCopySpec(runtime *common.RuntimeContext) (driveCopySpec, error) { + ref, err := resolveDriveCopyInput(runtime.Str("url"), runtime.Str("token"), runtime.Str("type")) + if err != nil { + return driveCopySpec{}, err + } + spec := driveCopySpec{ + Ref: ref, + Name: strings.TrimSpace(runtime.Str("name")), + } + spec.FolderToken, spec.FolderMySpace, err = resolveDriveCopyFolderToken(runtime.Str("folder-token")) + if err != nil { + return driveCopySpec{}, err + } + spec.Extras, err = parseDriveCopyExtras(runtime.StrArray("extra")) + if err != nil { + return driveCopySpec{}, err + } + if err := validateDriveCopySpec(spec); err != nil { + return driveCopySpec{}, err + } + return spec, nil +} + +// parseDriveCopyExtras converts repeated `key=value` specs into the API's +// extra parameter shape, preserving order and transcribing values verbatim. +func parseDriveCopyExtras(specs []string) ([]driveCopyExtra, error) { + if len(specs) == 0 { + return nil, nil + } + extras := make([]driveCopyExtra, 0, len(specs)) + for _, spec := range specs { + key, value, found := strings.Cut(spec, "=") + if !found { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --extra %q: expected format key=value", spec).WithParam("--extra") + } + key = strings.TrimSpace(key) + if key == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --extra %q: key must not be empty", spec).WithParam("--extra") + } + if value == "" { + return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --extra %q: value must not be empty", spec).WithParam("--extra") + } + extras = append(extras, driveCopyExtra{Key: key, Value: value}) + } + return extras, nil +} + +func validateDriveCopySpec(spec driveCopySpec) error { + if spec.Name == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name must not be empty or whitespace-only").WithParam("--name") + } + if len(spec.Name) > driveCopyMaxNameBytes { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--name exceeds %d bytes (got %d)", driveCopyMaxNameBytes, len(spec.Name)).WithParam("--name") + } + return nil +} + +func resolveDriveCopyInput(urlInput, tokenInput, explicitType string) (driveCopyRef, error) { + urlInput = strings.TrimSpace(urlInput) + tokenInput = strings.TrimSpace(tokenInput) + if urlInput != "" && tokenInput != "" { + return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--url and --token are mutually exclusive; pass one input only").WithParam("--url") + } + if urlInput == "" && tokenInput == "" { + return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "specify --url or --token").WithParam("--url") + } + + raw := urlInput + sourceFlag := "--url" + if raw == "" { + raw = tokenInput + sourceFlag = "--token" + } + inputType := normalizeDriveCopyType(strings.ToLower(strings.TrimSpace(explicitType))) + + if ref, ok := common.ParseResourceURL(raw); ok { + refType := normalizeDriveCopyType(ref.Type) + if inputType != "" && inputType != refType { + return driveCopyRef{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--type %q conflicts with URL path type %q; remove --type or use a matching value", + inputType, + refType, + ).WithParam("--type") + } + if refType == "wiki" { + return driveCopyRef{}, driveCopyWikiRedirectError(sourceFlag, ref.Token) + } + if !driveCopyTypeSupported(refType) { + return driveCopyRef{}, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "unsupported %s resource type %q; drive copy supports doc, docx, sheet, file, mindnote, slides, and bitable/base", + sourceFlag, + refType, + ).WithParam(sourceFlag) + } + return driveCopyRef{Token: ref.Token, Type: refType, SourceFlag: sourceFlag}, nil + } + + if strings.Contains(raw, "://") { + return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported %s URL %q: use a recognized Lark document URL or pass a bare token with --type", sourceFlag, raw).WithParam(sourceFlag) + } + if strings.ContainsAny(raw, "/?#") { + return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid bare token %q: remove path/query fragments or pass a recognized Lark document URL", raw).WithParam(sourceFlag) + } + if inputType == "" { + return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "--type is required when %s is a bare token (allowed: doc, docx, sheet, file, mindnote, slides, bitable, base)", sourceFlag).WithParam("--type") + } + if inputType == "wiki" { + return driveCopyRef{}, driveCopyWikiRedirectError("--type", raw) + } + if !driveCopyTypeSupported(inputType) { + return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, mindnote, slides, bitable, base", inputType).WithParam("--type") + } + return driveCopyRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil +} + +// driveCopyWikiRedirectError guides wiki inputs to the dedicated wiki copy +// command instead of the Drive copy API, which cannot place copies in the +// wiki tree. +func driveCopyWikiRedirectError(param, nodeToken string) *errs.ValidationError { + return errs.NewValidationError( + errs.SubtypeInvalidArgument, + "wiki node %q cannot be copied with drive +copy; use wiki +node-copy instead", + nodeToken, + ).WithParam(param).WithHint( + "run: lark-cli wiki +node-copy --space-id --node-token %s --target-space-id (or --target-parent-node-token); resolve with: lark-cli wiki +node-get --token %s", + nodeToken, + nodeToken, + ) +} + +func resolveDriveCopyFolderToken(input string) (string, bool, error) { + input = strings.TrimSpace(input) + if strings.EqualFold(input, driveCopyMySpaceSentinel) { + return "", true, nil + } + if ref, ok := common.ParseResourceURL(input); ok { + if ref.Type != "folder" { + return "", false, errs.NewValidationError( + errs.SubtypeInvalidArgument, + "--folder-token URL resolves to %q, not a folder; pass a folder URL, a folder token, or my_space", + ref.Type, + ).WithParam("--folder-token") + } + return ref.Token, false, nil + } + if err := validate.ResourceName(input, "--folder-token"); err != nil { + return "", false, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--folder-token") + } + return input, false, nil +} + +// resolveDriveCopyMySpaceRoot fetches the caller's My Space root folder token. +// The endpoint is absent from platform metadata; the path follows the official +// get-root-folder-meta documentation and works for both user and bot tokens. +func resolveDriveCopyMySpaceRoot(runtime *common.RuntimeContext) (string, error) { + fmt.Fprintf(runtime.IO().ErrOut, "Resolving My Space root folder...\n") + data, err := runtime.CallAPITyped("GET", driveCopyRootFolderMetaPath, nil, nil) + if err != nil { + return "", err + } + token := strings.TrimSpace(common.GetString(data, "token")) + if token == "" { + return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "root folder meta returned an empty token") + } + fmt.Fprintf(runtime.IO().ErrOut, "Resolved My Space root: %s\n", common.MaskToken(token)) + return token, nil +} + +func normalizeDriveCopyType(docType string) string { + switch strings.TrimSpace(docType) { + case "base": + return "bitable" + default: + return strings.TrimSpace(docType) + } +} + +func driveCopyTypeSupported(docType string) bool { + switch normalizeDriveCopyType(docType) { + case "doc", "docx", "sheet", "file", "mindnote", "slides", "bitable": + return true + default: + return false + } +} + +func buildDriveCopyBody(spec driveCopySpec, folderToken string) map[string]interface{} { + body := map[string]interface{}{ + "name": spec.Name, + "type": spec.Ref.Type, + "folder_token": folderToken, + } + if len(spec.Extras) > 0 { + extras := make([]map[string]interface{}, 0, len(spec.Extras)) + for _, extra := range spec.Extras { + extras = append(extras, map[string]interface{}{"key": extra.Key, "value": extra.Value}) + } + body["extra"] = extras + } + return body +} + +func buildDriveCopyDryRun(spec driveCopySpec) *common.DryRunAPI { + if spec.FolderMySpace { + return common.NewDryRunAPI(). + Desc("2-step orchestration: resolve My Space root -> copy"). + GET(driveCopyRootFolderMetaPath). + Desc("[1] Resolve the caller's My Space root folder token"). + POST("/open-apis/drive/v1/files/:file_token/copy"). + Desc("[2] Copy file into the resolved root folder"). + Body(buildDriveCopyBody(spec, "")). + Set("file_token", spec.Ref.Token) + } + return common.NewDryRunAPI(). + Desc("1-step request: copy file into target folder"). + POST("/open-apis/drive/v1/files/:file_token/copy"). + Body(buildDriveCopyBody(spec, spec.FolderToken)). + Set("file_token", spec.Ref.Token) +} + +func buildDriveCopyOutput(runtime *common.RuntimeContext, spec driveCopySpec, folderToken string, data map[string]interface{}) map[string]interface{} { + out := map[string]interface{}{ + "copied": true, + "source_file_token": spec.Ref.Token, + "source_type": spec.Ref.Type, + "folder_token": folderToken, + } + file := common.GetMap(data, "file") + if token := common.GetString(file, "token"); token != "" { + out["file_token"] = token + if url := common.GetString(file, "url"); url != "" { + out["url"] = url + } else if built := common.BuildResourceURL(runtime.Config.Brand, common.GetString(file, "type"), token); built != "" { + out["url"] = built + } + } + if fileType := common.GetString(file, "type"); fileType != "" { + out["file_type"] = fileType + } + if name := common.GetString(file, "name"); name != "" { + out["name"] = name + } + return out +} diff --git a/shortcuts/drive/drive_copy_test.go b/shortcuts/drive/drive_copy_test.go new file mode 100644 index 0000000000..64169edde5 --- /dev/null +++ b/shortcuts/drive/drive_copy_test.go @@ -0,0 +1,811 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" +) + +func TestResolveDriveCopyInput(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + urlInput string + rawInput string + docType string + wantToken string + wantType string + wantErr string + wantParam string + }{ + { + name: "url docx", + urlInput: "https://example.larksuite.com/docx/docxCopySource?from=share", + wantToken: "docxCopySource", + wantType: "docx", + }, + { + name: "url base normalizes to bitable", + urlInput: "https://example.larksuite.com/base/bitableCopySource", + wantToken: "bitableCopySource", + wantType: "bitable", + }, + { + name: "token flag also accepts url", + rawInput: "https://example.larksuite.com/sheets/sheetCopySource", + wantToken: "sheetCopySource", + wantType: "sheet", + }, + { + name: "bare token with type", + rawInput: "mindnoteCopySource", + docType: "mindnote", + wantToken: "mindnoteCopySource", + wantType: "mindnote", + }, + { + name: "bare token with base alias", + rawInput: "bitableCopySource", + docType: "base", + wantToken: "bitableCopySource", + wantType: "bitable", + }, + { + name: "url and token mutually exclusive", + urlInput: "https://example.larksuite.com/docx/docxCopySource", + rawInput: "docxCopySource", + wantErr: "mutually exclusive", + wantParam: "--url", + }, + { + name: "missing input", + wantErr: "specify --url or --token", + wantParam: "--url", + }, + { + name: "bare token needs type", + rawInput: "docxCopySource", + wantErr: "--type is required", + wantParam: "--type", + }, + { + name: "type conflicts with url", + urlInput: "https://example.larksuite.com/docx/docxCopySource", + docType: "sheet", + wantErr: "conflicts", + wantParam: "--type", + }, + { + name: "folder url unsupported as source", + urlInput: "https://example.larksuite.com/drive/folder/folderCopySource", + wantErr: "unsupported", + wantParam: "--url", + }, + { + name: "unrecognized url", + urlInput: "https://example.larksuite.com/unknown/path", + wantErr: "unsupported --url URL", + wantParam: "--url", + }, + { + name: "token with path fragments", + rawInput: "token/with/slash", + wantErr: "invalid bare token", + wantParam: "--token", + }, + { + name: "invalid bare type", + rawInput: "someToken", + docType: "folder", + wantErr: "invalid --type", + wantParam: "--type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := resolveDriveCopyInput(tt.urlInput, tt.rawInput, tt.docType) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + assertDriveCopyValidationError(t, err, tt.wantParam) + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Token != tt.wantToken || got.Type != tt.wantType { + t.Fatalf("got (%q, %q), want (%q, %q)", got.Token, got.Type, tt.wantToken, tt.wantType) + } + }) + } +} + +func TestResolveDriveCopyInputWikiRedirect(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + urlInput string + rawInput string + docType string + wantParam string + }{ + { + name: "wiki url", + urlInput: "https://example.larksuite.com/wiki/wikiCopySource", + wantParam: "--url", + }, + { + name: "wiki url via token flag", + rawInput: "https://example.larksuite.com/wiki/wikiCopySource", + wantParam: "--token", + }, + { + name: "bare token with wiki type", + rawInput: "wikiCopySource", + docType: "wiki", + wantParam: "--type", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + _, err := resolveDriveCopyInput(tt.urlInput, tt.rawInput, tt.docType) + if err == nil { + t.Fatal("expected wiki redirect error, got nil") + } + assertDriveCopyValidationError(t, err, tt.wantParam) + + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if !strings.Contains(validationErr.Message, "wiki +node-copy") { + t.Fatalf("message should redirect to wiki +node-copy, got %q", validationErr.Message) + } + if !strings.Contains(validationErr.Hint, "wiki +node-copy --space-id") { + t.Fatalf("hint should carry the wiki +node-copy command, got %q", validationErr.Hint) + } + if !strings.Contains(validationErr.Hint, "--node-token wikiCopySource") { + t.Fatalf("hint should carry the parsed node token, got %q", validationErr.Hint) + } + if !strings.Contains(validationErr.Hint, "wiki +node-get --token wikiCopySource") { + t.Fatalf("hint should explain how to resolve the space id, got %q", validationErr.Hint) + } + }) + } +} + +func TestResolveDriveCopyFolderToken(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input string + wantToken string + wantMySpace bool + wantErr string + }{ + { + name: "bare folder token", + input: "folderCopyTarget", + wantToken: "folderCopyTarget", + }, + { + name: "folder url", + input: "https://example.larksuite.com/drive/folder/folderCopyTarget", + wantToken: "folderCopyTarget", + }, + { + name: "my_space sentinel", + input: "my_space", + wantMySpace: true, + }, + { + name: "my_space sentinel is case-insensitive and trimmed", + input: " MY_SPACE ", + wantMySpace: true, + }, + { + name: "non-folder url", + input: "https://example.larksuite.com/docx/docxCopyTarget", + wantErr: "not a folder", + }, + { + name: "empty input", + input: " ", + wantErr: "--folder-token", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, mySpace, err := resolveDriveCopyFolderToken(tt.input) + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + assertDriveCopyValidationError(t, err, "--folder-token") + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mySpace != tt.wantMySpace { + t.Fatalf("mySpace = %v, want %v", mySpace, tt.wantMySpace) + } + if got != tt.wantToken { + t.Fatalf("token = %q, want %q", got, tt.wantToken) + } + }) + } +} + +func TestParseDriveCopyExtras(t *testing.T) { + t.Parallel() + + extras, err := parseDriveCopyExtras(nil) + if err != nil || extras != nil { + t.Fatalf("empty specs = (%#v, %v), want (nil, nil)", extras, err) + } + + extras, err = parseDriveCopyExtras([]string{"target_type=docx", "flag=a=b"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []driveCopyExtra{{Key: "target_type", Value: "docx"}, {Key: "flag", Value: "a=b"}} + if len(extras) != len(want) { + t.Fatalf("extras = %#v, want %#v", extras, want) + } + for i := range want { + if extras[i] != want[i] { + t.Fatalf("extras[%d] = %#v, want %#v (order and values must be preserved verbatim)", i, extras[i], want[i]) + } + } + + for _, bad := range []string{"no-separator", "=docx", " =docx", "target_type="} { + _, err := parseDriveCopyExtras([]string{bad}) + if err == nil || !strings.Contains(err.Error(), "invalid --extra") { + t.Fatalf("spec %q: expected invalid --extra error, got %v", bad, err) + } + assertDriveCopyValidationError(t, err, "--extra") + } +} + +func TestBuildDriveCopyBodyExtras(t *testing.T) { + t.Parallel() + + spec := driveCopySpec{ + Ref: driveCopyRef{Token: "docCopySource", Type: "doc", SourceFlag: "--url"}, + Name: "Copied doc", + FolderToken: "folderCopyTarget", + } + if _, ok := buildDriveCopyBody(spec, spec.FolderToken)["extra"]; ok { + t.Fatal("body should omit extra when no --extra is passed") + } + + spec.Extras = []driveCopyExtra{{Key: "target_type", Value: "docx"}} + body := buildDriveCopyBody(spec, spec.FolderToken) + extras, ok := body["extra"].([]map[string]interface{}) + if !ok || len(extras) != 1 { + t.Fatalf("body extra = %#v, want 1 key/value entry", body["extra"]) + } + if extras[0]["key"] != "target_type" || extras[0]["value"] != "docx" { + t.Fatalf("extra[0] = %#v, want target_type=docx", extras[0]) + } +} + +func TestValidateDriveCopySpec(t *testing.T) { + t.Parallel() + + base := driveCopySpec{ + Ref: driveCopyRef{Token: "docxCopySource", Type: "docx", SourceFlag: "--url"}, + Name: "Copy name", + FolderToken: "folderCopyTarget", + } + + if err := validateDriveCopySpec(base); err != nil { + t.Fatalf("unexpected error for valid spec: %v", err) + } + + empty := base + empty.Name = "" + err := validateDriveCopySpec(empty) + if err == nil || !strings.Contains(err.Error(), "--name must not be empty") { + t.Fatalf("expected empty-name error, got %v", err) + } + assertDriveCopyValidationError(t, err, "--name") + + long := base + long.Name = strings.Repeat("字", 90) // 270 bytes in UTF-8 + err = validateDriveCopySpec(long) + if err == nil || !strings.Contains(err.Error(), "exceeds 256 bytes") { + t.Fatalf("expected name-length error, got %v", err) + } + assertDriveCopyValidationError(t, err, "--name") +} + +func assertDriveCopyValidationError(t *testing.T, err error, wantParam string) { + t.Helper() + + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T: %v", err, err) + } + if validationErr.Category != errs.CategoryValidation { + t.Fatalf("category = %q, want %q", validationErr.Category, errs.CategoryValidation) + } + if validationErr.Subtype != errs.SubtypeInvalidArgument { + t.Fatalf("subtype = %q, want %q", validationErr.Subtype, errs.SubtypeInvalidArgument) + } + if validationErr.Param != wantParam { + t.Fatalf("param = %q, want %q", validationErr.Param, wantParam) + } + if cause := errors.Unwrap(err); cause != nil { + t.Fatalf("unexpected cause on direct validation error: %v", cause) + } + + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected errs.ProblemOf to recognize typed error: %v", err) + } + if problem.Category != errs.CategoryValidation { + t.Fatalf("problem category = %q, want %q", problem.Category, errs.CategoryValidation) + } +} + +func TestDriveCopyExecuteDocx(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + copyStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/docxCopySource/copy", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "file": map[string]interface{}{ + "token": "docxCopyResult", + "type": "docx", + "name": "Copied doc", + "url": "https://example.larksuite.com/docx/docxCopyResult", + "parent_token": "folderCopyTarget", + }, + }, + }, + } + reg.Register(copyStub) + + err := mountAndRunDrive(t, DriveCopy, []string{ + "+copy", + "--url", "https://example.larksuite.com/docx/docxCopySource", + "--name", "Copied doc", + "--folder-token", "https://example.larksuite.com/drive/folder/folderCopyTarget", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var requestBody map[string]interface{} + if err := json.Unmarshal(copyStub.CapturedBody, &requestBody); err != nil { + t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(copyStub.CapturedBody)) + } + if got := requestBody["name"]; got != "Copied doc" { + t.Fatalf("body name = %#v, want Copied doc", got) + } + if got := requestBody["type"]; got != "docx" { + t.Fatalf("body type = %#v, want docx", got) + } + if got := requestBody["folder_token"]; got != "folderCopyTarget" { + t.Fatalf("body folder_token = %#v, want folderCopyTarget (parsed from folder URL)", got) + } + + out := decodeJSONMap(t, stdout.String()) + data := mustMapValue(t, out["data"], "data") + if got := data["copied"]; got != true { + t.Fatalf("copied = %#v, want true", got) + } + if got := mustStringField(t, data, "file_token", "data.file_token"); got != "docxCopyResult" { + t.Fatalf("file_token = %q, want docxCopyResult", got) + } + if got := mustStringField(t, data, "file_type", "data.file_type"); got != "docx" { + t.Fatalf("file_type = %q, want docx", got) + } + if got := mustStringField(t, data, "url", "data.url"); got != "https://example.larksuite.com/docx/docxCopyResult" { + t.Fatalf("url = %q, want backend url", got) + } + if got := mustStringField(t, data, "source_file_token", "data.source_file_token"); got != "docxCopySource" { + t.Fatalf("source_file_token = %q, want docxCopySource", got) + } +} + +func TestDriveCopyExecuteBuildsURLFallback(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/sheetCopySource/copy", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "file": map[string]interface{}{ + "token": "sheetCopyResult", + "type": "sheet", + "name": "Copied sheet", + }, + }, + }, + }) + + err := mountAndRunDrive(t, DriveCopy, []string{ + "+copy", + "--token", "sheetCopySource", + "--type", "sheet", + "--name", "Copied sheet", + "--folder-token", "folderCopyTarget", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := decodeJSONMap(t, stdout.String()) + data := mustMapValue(t, out["data"], "data") + url := mustStringField(t, data, "url", "data.url") + if !strings.HasSuffix(url, "/sheets/sheetCopyResult") { + t.Fatalf("url = %q, want built fallback ending in /sheets/sheetCopyResult", url) + } +} + +func TestDriveCopyExecuteAPIError(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/docxCopySource/copy", + Body: map[string]interface{}{ + "code": 1248006, + "msg": "no permission", + }, + }) + + err := mountAndRunDrive(t, DriveCopy, []string{ + "+copy", + "--token", "docxCopySource", + "--type", "docx", + "--name", "Copied doc", + "--folder-token", "folderCopyTarget", + "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected API error, got nil") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if problem.Code != 1248006 { + t.Fatalf("problem code = %d, want 1248006", problem.Code) + } +} + +func TestDriveCopyMountedDryRun(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + err := mountAndRunDrive(t, DriveCopy, []string{ + "+copy", + "--url", "https://example.larksuite.com/docx/docxCopySource", + "--name", "Copied doc", + "--folder-token", "folderCopyTarget", + "--extra", "target_type=docx", + "--dry-run", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + out := decodeJSONMap(t, stdout.String()) + if got := out["dry_run"]; got != true { + t.Fatalf("dry_run = %#v, want true\nstdout:\n%s", got, stdout.String()) + } + data := mustMapValue(t, out["data"], "data") + apis, ok := data["api"].([]interface{}) + if !ok || len(apis) != 1 { + t.Fatalf("expected 1 api entry, got %#v\nstdout:\n%s", data["api"], stdout.String()) + } + call := mustMapValue(t, apis[0], "api.0") + if got := call["url"]; got != "/open-apis/drive/v1/files/docxCopySource/copy" { + t.Fatalf("url = %#v, want resolved copy endpoint", got) + } + body := mustMapValue(t, call["body"], "api.0.body") + if got := body["folder_token"]; got != "folderCopyTarget" { + t.Fatalf("body folder_token = %#v, want folderCopyTarget", got) + } + extras, ok := body["extra"].([]interface{}) + if !ok || len(extras) != 1 { + t.Fatalf("body extra = %#v, want 1 entry", body["extra"]) + } + extra := mustMapValue(t, extras[0], "api.0.body.extra.0") + if extra["key"] != "target_type" || extra["value"] != "docx" { + t.Fatalf("extra[0] = %#v, want target_type=docx", extra) + } +} + +func TestDriveCopyMountedMySpaceExecute(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/explorer/v2/root_folder/meta", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "id": "7000000000000000001", + "token": "rootFolderResolved", + "user_id": "7000000000000000002", + }, + }, + }) + copyStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/docxCopySource/copy", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{ + "file": map[string]interface{}{ + "token": "docxCopyResult", + "type": "docx", + "name": "Copied doc", + }, + }, + }, + } + reg.Register(copyStub) + + err := mountAndRunDrive(t, DriveCopy, []string{ + "+copy", + "--token", "docxCopySource", + "--type", "docx", + "--name", "Copied doc", + "--folder-token", "my_space", + "--as", "user", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var requestBody map[string]interface{} + if err := json.Unmarshal(copyStub.CapturedBody, &requestBody); err != nil { + t.Fatalf("failed to decode captured body: %v\nbody:\n%s", err, string(copyStub.CapturedBody)) + } + if got := requestBody["folder_token"]; got != "rootFolderResolved" { + t.Fatalf("body folder_token = %#v, want resolved root token", got) + } + + out := decodeJSONMap(t, stdout.String()) + data := mustMapValue(t, out["data"], "data") + if got := mustStringField(t, data, "folder_token", "data.folder_token"); got != "rootFolderResolved" { + t.Fatalf("output folder_token = %q, want resolved root token", got) + } +} + +func TestDriveCopyMountedMySpaceRootResolveErrors(t *testing.T) { + t.Run("api error propagates", func(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/explorer/v2/root_folder/meta", + Body: map[string]interface{}{ + "code": 99991663, + "msg": "token invalid", + }, + }) + + err := mountAndRunDrive(t, DriveCopy, []string{ + "+copy", + "--token", "docxCopySource", + "--type", "docx", + "--name", "Copied doc", + "--folder-token", "my_space", + "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected root resolve error, got nil") + } + problem, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("expected typed error, got %T: %v", err, err) + } + if problem.Code != 99991663 { + t.Fatalf("problem code = %d, want 99991663", problem.Code) + } + }) + + t.Run("empty token is an internal error", func(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/explorer/v2/root_folder/meta", + Body: map[string]interface{}{ + "code": 0, + "msg": "success", + "data": map[string]interface{}{"id": "7000000000000000001"}, + }, + }) + + err := mountAndRunDrive(t, DriveCopy, []string{ + "+copy", + "--token", "docxCopySource", + "--type", "docx", + "--name", "Copied doc", + "--folder-token", "my_space", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "empty token") { + t.Fatalf("expected empty-token error, got %v", err) + } + var internalErr *errs.InternalError + if !errors.As(err, &internalErr) { + t.Fatalf("expected *errs.InternalError, got %T: %v", err, err) + } + if internalErr.Subtype != errs.SubtypeInvalidResponse { + t.Fatalf("subtype = %q, want %q", internalErr.Subtype, errs.SubtypeInvalidResponse) + } + }) +} + +func TestBuildDriveCopyDryRunMySpace(t *testing.T) { + t.Parallel() + + spec := driveCopySpec{ + Ref: driveCopyRef{Token: "docxCopySource", Type: "docx", SourceFlag: "--url"}, + Name: "Copied doc", + FolderMySpace: true, + } + raw, err := json.Marshal(buildDriveCopyDryRun(spec)) + if err != nil { + t.Fatalf("failed to marshal dry-run preview: %v", err) + } + payload := decodeJSONMap(t, string(raw)) + + apis, ok := payload["api"].([]interface{}) + if !ok || len(apis) != 2 { + t.Fatalf("expected 2 api entries, got %#v", payload["api"]) + } + step1 := mustMapValue(t, apis[0], "api.0") + if step1["method"] != "GET" || step1["url"] != "/open-apis/drive/explorer/v2/root_folder/meta" { + t.Fatalf("api.0 = %#v, want root folder meta GET", step1) + } + step2 := mustMapValue(t, apis[1], "api.1") + body := mustMapValue(t, step2["body"], "api.1.body") + if got := body["folder_token"]; got != "" { + t.Fatalf("api.1.body.folder_token = %#v, want placeholder", got) + } +} + +func TestDriveCopyMountedWikiInputFailsValidation(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + err := mountAndRunDrive(t, DriveCopy, []string{ + "+copy", + "--url", "https://example.larksuite.com/wiki/wikiCopySource", + "--name", "Copied wiki", + "--folder-token", "folderCopyTarget", + "--as", "user", + }, f, stdout) + if err == nil { + t.Fatal("expected wiki redirect error, got nil") + } + assertDriveCopyValidationError(t, err, "--url") + + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected *errs.ValidationError, got %T", err) + } + if !strings.Contains(validationErr.Hint, "wiki +node-copy") { + t.Fatalf("hint should redirect to wiki +node-copy, got %q", validationErr.Hint) + } +} + +func TestDriveCopyMountedFolderAndNameValidation(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + err := mountAndRunDrive(t, DriveCopy, []string{ + "+copy", + "--token", "docxCopySource", + "--type", "docx", + "--name", "Copied doc", + "--folder-token", "https://example.larksuite.com/docx/notAFolder", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "not a folder") { + t.Fatalf("expected non-folder target error, got %v", err) + } + assertDriveCopyValidationError(t, err, "--folder-token") + + err = mountAndRunDrive(t, DriveCopy, []string{ + "+copy", + "--token", "docxCopySource", + "--type", "docx", + "--name", " ", + "--folder-token", "folderCopyTarget", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--name must not be empty") { + t.Fatalf("expected whitespace-name error, got %v", err) + } + assertDriveCopyValidationError(t, err, "--name") + + err = mountAndRunDrive(t, DriveCopy, []string{ + "+copy", + "--token", "docxCopySource", + "--type", "docx", + "--name", "Copied doc", + "--folder-token", "folderCopyTarget", + "--extra", "no-separator", + "--as", "user", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "expected format key=value") { + t.Fatalf("expected malformed --extra error, got %v", err) + } + assertDriveCopyValidationError(t, err, "--extra") +} + +func TestBuildDriveCopyDryRun(t *testing.T) { + t.Parallel() + + spec := driveCopySpec{ + Ref: driveCopyRef{Token: "docxCopySource", Type: "docx", SourceFlag: "--url"}, + Name: "Copied doc", + FolderToken: "folderCopyTarget", + } + preview := buildDriveCopyDryRun(spec) + raw, err := json.Marshal(preview) + if err != nil { + t.Fatalf("failed to marshal dry-run preview: %v", err) + } + payload := decodeJSONMap(t, string(raw)) + + apis, ok := payload["api"].([]interface{}) + if !ok || len(apis) != 1 { + t.Fatalf("expected 1 api entry, got %#v", payload["api"]) + } + call := mustMapValue(t, apis[0], "api.0") + if got := call["method"]; got != "POST" { + t.Fatalf("method = %#v, want POST", got) + } + if got := call["url"]; got != "/open-apis/drive/v1/files/docxCopySource/copy" { + t.Fatalf("url = %#v, want resolved copy endpoint", got) + } + body := mustMapValue(t, call["body"], "api.0.body") + if got := body["type"]; got != "docx" { + t.Fatalf("body type = %#v, want docx", got) + } + if got := body["name"]; got != "Copied doc" { + t.Fatalf("body name = %#v, want Copied doc", got) + } + if got := body["folder_token"]; got != "folderCopyTarget" { + t.Fatalf("body folder_token = %#v, want folderCopyTarget", got) + } + if got := payload["file_token"]; got != "docxCopySource" { + t.Fatalf("file_token = %#v, want docxCopySource", got) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index 33183bd6ac..7eb5803a92 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut { DriveUpload, DriveCreateFolder, DriveCreateShortcut, + DriveCopy, DriveDownload, DrivePreview, DriveCover, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 27444a566b..8f610472ef 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -18,6 +18,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+upload", "+create-folder", "+create-shortcut", + "+copy", "+download", "+preview", "+cover", diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index d14a5a9b5f..bc5389bdd0 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -16,12 +16,12 @@ metadata: > **导入分流规则:** 如果用户要把本地 Excel / CSV / `.base` 快照导入成 Base / 多维表格 / bitable,必须优先使用 `lark-cli drive +import --type bitable`。不要先切到 `lark-base`;`lark-base` 只负责导入完成后的表内操作。 -> **副本分流规则:** 如果用户要复制在线文档、创建文档副本、把文档复制到另一个文件夹,必须使用 `lark-cli drive files copy`。不要用 `drive +export` 下载后再 `drive +import` 上传,也不要用 `docs +fetch` + `docs +create` 重建正文;导出/导入只用于本地文件转换或离线产物。 +> **副本分流规则:** 如果用户要复制在线文档、创建文档副本、把文档复制到另一个文件夹,必须使用 `lark-cli drive +copy`。不要用 `drive +export` 下载后再 `drive +import` 上传,也不要用 `docs +fetch` + `docs +create` 重建正文;导出/导入只用于本地文件转换或离线产物。 ## 快速决策 - 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:切到 `lark-wiki`,使用 `lark-cli wiki +move-to-drive`;不要把 Wiki token 直接交给 `drive +move`。这是会改变文档归属和权限继承的写操作,执行前确认源节点与目标位置。 -- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token,先用 `lark-cli drive +inspect` 获取底层 `token` 和 `type`,不要把 wiki token 直接当 `file_token`。`params.file_token` 传源文档 token,`data.folder_token` 传目标文件夹 token,`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":""}' --data '{"folder_token":"","name":"","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。 +- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive +copy`,用法见 [`references/lark-drive-copy.md`](references/lark-drive-copy.md);如果是 wiki URL/token,使用 `wiki +node-copy`,见 [`lark-wiki-node-copy.md`](../lark-wiki/references/lark-wiki-node-copy.md)。 - 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url ''` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。 - 高风险写操作(删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。 - 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要”权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。 @@ -128,6 +128,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | `+sync` | 双向同步本地目录与 Drive 文件夹:拉取 `new_remote`、推送 `new_local`,`modified` 按 `--on-conflict=remote-wins\|local-wins\|keep-both\|ask` 处理;`--quick` 用修改时间近似比较;`--on-duplicate-remote` 支持 `fail` / `newest` / `oldest`;只同步 `type=file`,跳过在线文档和 shortcut,且不会删除两端多余文件。 | | [`+push`](references/lark-drive-push.md) | 将本地目录推送到 Drive 文件夹,支持 skip / smart / overwrite 与确认后删除远端。 | | [`+create-shortcut`](references/lark-drive-create-shortcut.md) | 在另一个文件夹里创建现有 Drive 文件的快捷方式。 | +| [`+copy`](references/lark-drive-copy.md) | 复制 doc/docx/sheet/file/mindnote/slides/base(bitable) 到目标文件夹;支持 URL 传参,wiki 输入会引导改用 `wiki +node-copy`。 | | [`+add-comment`](references/lark-drive-add-comment.md) | 给 doc/docx/file/sheet/slides/base(bitable) 添加全文/局部评论;不支持妙搭 apps。 | | [`+list-comments`](references/lark-drive-list-comments.md) | 分页获取评论列表。 | | [`+batch-query-comments`](references/lark-drive-batch-query-comments.md) | 按评论 ID 批量获取评论。 | @@ -170,7 +171,7 @@ lark-cli drive [flags] # 调用 API ### files - - `copy` — 复制文件;在线文档创建副本的首选能力,完整参数见上方“快速决策”,不要用 `drive +export` / `drive +import` 绕行复制 + - `copy` — 复制文件;优先使用 [`drive +copy`](references/lark-drive-copy.md) shortcut - `create_folder` — 新建文件夹 - `list` — 获取文件夹下的清单;使用前阅读 [`references/lark-drive-files-list.md`](references/lark-drive-files-list.md) - `patch` — 修改文件标题 diff --git a/skills/lark-drive/references/lark-drive-copy.md b/skills/lark-drive/references/lark-drive-copy.md new file mode 100644 index 0000000000..2778a95edc --- /dev/null +++ b/skills/lark-drive/references/lark-drive-copy.md @@ -0,0 +1,62 @@ + +# drive +copy + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +复制一个 Drive 文件(在线文档、表格、多维表格、幻灯片、思维笔记或普通文件)到目标文件夹,生成一个内容相同的新副本。推荐直接传文档 URL。 + +## 命令 + +```bash +# 推荐:源文档传 URL(自动识别类型和 token) +lark-cli drive +copy --url "https://example.larksuite.com/docx/" --name '副本名称' --folder-token +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--url` | 与 `--token` 二选一 | 源文档 URL,支持 `doc` / `docx` / `sheet` / `file` / `mindnote` / `slides` / `base` / `bitable` 路径 | +| `--token` | 与 `--url` 二选一 | 源文档 token 或 URL;裸 token 必须配合 `--type` | +| `--type` | 裸 token 时必填 | 源文件类型:`doc`、`docx`、`sheet`、`file`、`mindnote`、`slides`、`bitable`(`base` 为兼容别名);传 URL 时可省略,显式传入时必须与 URL 类型一致 | +| `--name` | 是 | 副本名称,最长 256 字节 | +| `--folder-token` | 是 | 目标文件夹 token、文件夹 URL,或常量 `my_space`(复制到当前身份"我的空间"根目录,内部自动解析根 token) | +| `--extra` | 否 | 可重复的 `key=value` 对,原样透传给 API 的 `extra` 自定义复制参数;典型用法 `--extra target_type=docx`(复制旧版 doc 时转换为 docx 副本) | + +## 输入规则 + +- `--url` 与 `--token` 互斥,只传一个 +- `--type` 必须与源文件真实类型一致,类型不匹配时服务端会返回失败 +- `base` 与 `bitable` 是同一概念,CLI 会把 `base` 归一化为 `bitable` 后发给服务端 +- 目标文件夹必须是云空间(云盘/云存储)文件夹 token,不能传 wiki 节点 token + +## Wiki 场景 + +`drive +copy` 只复制云盘(Drive)文件,不接受 wiki URL / token;传入时返回校验错误,错误 hint 会给出替代命令。知识库内复制节点用 [`lark-wiki`](../../lark-wiki/SKILL.md) 的 `wiki +node-copy`;要把 wiki 文档复制成 Drive 空间里的独立副本(脱离知识库),先用 `drive +inspect` 解包拿到底层 `token` 和 `type`,再对底层 token 执行 `drive +copy`。 + +## 行为说明 + +- 该 shortcut 继承通用能力,可配合 `--as user|bot|auto`、`--format`、`--jq`、`--dry-run` 使用 +- `--dry-run` 只输出请求方法、路径、身份和请求体预览,不会真正创建副本 +- 这是写入操作;执行前应确认源文档和目标文件夹准确无误 + +## 输出 + +```json +{ + "copied": true, + "file_token": "", + "file_type": "docx", + "name": "副本名称", + "url": "https://example.larksuite.com/docx/", + "source_file_token": "", + "source_type": "docx", + "folder_token": "" +} +``` + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令 +- [lark-wiki](../../lark-wiki/SKILL.md) -- 知识库节点复制(`wiki +node-copy`) +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/tests/cli_e2e/drive/coverage.md b/tests/cli_e2e/drive/coverage.md index fac500cebf..e173b996f7 100644 --- a/tests/cli_e2e/drive/coverage.md +++ b/tests/cli_e2e/drive/coverage.md @@ -1,9 +1,9 @@ # Drive CLI E2E Coverage ## Metrics -- Denominator: 40 leaf commands -- Covered: 21 -- Coverage: 52.5% +- Denominator: 41 leaf commands +- Covered: 22 +- Coverage: 53.7% ## Summary - TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`. @@ -12,6 +12,8 @@ - TestDrive_DuplicateRemoteWorkflow: proves the duplicate-remote workflows against the real backend. One subtest uploads two same-name files into the same Drive folder and asserts `drive +status` and default `drive +pull` both fail with a typed validation error for the duplicate rel_path, while `drive +pull --on-duplicate-remote=rename` succeeds, downloads both files, and writes a hashed renamed sibling locally. The other subtest uploads duplicate remote files, runs `drive +push --on-duplicate-remote=newest --if-exists=overwrite --delete-remote --yes`, and then re-runs `drive +status` to prove the mirror converged to a single unchanged `dup.txt`. - TestDrive_ApplyPermissionDryRun / TestDrive_ApplyPermissionDryRunRejectsFullAccess: dry-run coverage for `drive +apply-permission`; asserts URL→type inference for docx/sheet/slides, explicit `--type` overriding URL inference when both a recognized URL and `--type` are supplied, bare-token + explicit `--type` path, request method/URL/type-query/perm/remark body shape, optional `remark` omission when unset, and client-side rejection of `--perm full_access`. Runs without hitting the live API. - TestDriveAddCommentDryRun_File / TestDriveAddCommentDryRun_Base: dry-run coverage for `drive +add-comment` on supported Drive file and Base targets; pins the `metas.batch_query -> files/:token/new_comments` file chain, Base `file_type=bitable`, and Base anchor fields. +- TestDriveCopyDryRun_DocxURL / TestDriveCopyDryRun_BareTokenBaseAlias / TestDriveCopyDryRun_MySpaceTarget / TestDriveCopyDryRun_WikiURLRedirectsToWikiNodeCopy: dry-run coverage for `drive +copy`; asserts URL parsing to `files/:token/copy`, request body shape (`name`/`type`/`folder_token`, `--extra key=value` → `extra` key/value array), folder-URL target parsing, `base`→`bitable` normalization, the `--folder-token my_space` two-step plan (`root_folder/meta -> copy` with a placeholder folder_token), and the typed validation error that redirects wiki inputs to `wiki +node-copy` (hint carries the parsed node token and the `wiki +node-get` space-id lookup). +- TestDrive_CopyWorkflow: proves `drive +copy` against the real backend. Uploads a source file into a temporary Drive folder, copies it with a new name via bare token + `--type file`, asserts the copy returns a distinct token with the requested name, downloads the copy, and verifies the content matches the source; a second copy targets `--folder-token my_space` and asserts the output carries the resolved root token instead of the sentinel. All copies and the folder are removed via best-effort cleanup hooks. The root-folder-meta resolution endpoint (absent from platform metadata) was live-verified for both user and bot identities. Live-probed manually beforehand as user: URL input, output shape, and the wiki redirect were confirmed against the live API. Error codes in the skill reference were also live-probed: nonexistent source token → 1061003, `--type` mismatch (docx declared as sheet) → 1061003 (not 1061002 — the server looks the token up under the declared type). - TestDriveListCommentsDryRun_DocxDefaults / TestDriveListCommentsDryRun_AppsPageURL / TestDriveListCommentsDryRun_WikiToken: dry-run coverage for `drive +list-comments`; asserts URL parsing to `files/:token/comments`, apps `/page/` URL parsing with `file_type=apps`, default `is_solved=false`, default omitted `is_whole` and `user_id_type`, and Wiki token orchestration (`get_node -> comments.list`) without live API calls. - TestDrive_CommentOpsDryRun: dry-run coverage for `drive +batch-query-comments`, `drive +resolve-comment`, `drive +restore-comment`, `drive +add-reply`, `drive +list-replies`, `drive +update-reply`, `drive +delete-reply`, and `drive +react-reply`; asserts URL→type inference (incl. Miaoda apps `/page/` → `file_type=apps` and Base `/base/` → `file_type=bitable`), `file_type`/`page_size`/`need_reaction`/`need_relation` (docx-gated) query wiring, `comment_ids` / `is_solved` / reply `content.elements[]` (text_run) / reaction `action`+`reaction_type`+`reply_id` body shapes, resolved `:comment_id`/`:reply_id` path segments, the Wiki `get_node -> batch_query` / `get_node -> replies list` / `get_node -> v2 reaction` orchestration plans, and the batch_query wiki dry-run surfacing `need_relation` as the `` placeholder, without live API calls. All eight verified manually against live documents (list → batch-query → add-reply → list-replies → update-reply → react add/delete → resolve/restore → delete-reply round trip; root-reply update rewriting the comment body, the `1069303 forbidden` cross-identity update rejection, the server persisting arbitrary `reaction_type` strings, and count=0 reaction tombstones were probed live as well). - TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for comment write/read, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file, adds a file comment, lists it back through `drive +list-comments`, and cleans up. @@ -40,6 +42,7 @@ | ✓ | drive +delete-reply | shortcut | drive_comment_ops_dryrun_test.go::TestDrive_CommentOpsDryRun; drive_comment_ops_workflow_test.go::TestDriveCommentOpsWorkflow | `--url` docx; `--comment-id` + `--reply-id` path segments; `--dry-run` bypasses the `--yes` gate | dry-run pins request shape; opt-in live workflow deletes the created reply and verifies the count returns to baseline | | ✓ | drive +react-reply | shortcut | drive_comment_ops_dryrun_test.go::TestDrive_CommentOpsDryRun; drive_comment_ops_workflow_test.go::TestDriveCommentOpsWorkflow | `--url` docx; `--token + --type wiki` resolve plan; v2 `.../comments/reaction` POST; `--reply-id`/`--emoji`/`--action add\|delete` → body `reply_id`/`reaction_type`/`action` | dry-run pins request/body shape and wiki resolve plan; opt-in live workflow adds then removes a reaction, polling `+list-replies --need-reaction` with count>0 presence checks; local `--emoji` enum validation guards the unvalidated server field | | ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner | +| ✓ | drive +copy | shortcut | drive_copy_dryrun_test.go::TestDriveCopyDryRun_DocxURL; drive_copy_dryrun_test.go::TestDriveCopyDryRun_BareTokenBaseAlias; drive_copy_dryrun_test.go::TestDriveCopyDryRun_WikiURLRedirectsToWikiNodeCopy; drive_copy_workflow_test.go::TestDrive_CopyWorkflow | `--url` doc URL vs bare `--token + --type`; `--type base` alias; `--name` body; `--folder-token` URL vs bare vs `my_space` sentinel; `--extra key=value` passthrough; wiki URL/token redirect to `wiki +node-copy` | dry-run locks request shape, the my_space two-step plan, and wiki redirect guidance; live workflow copies an uploaded file, verifies content via download, and copies into the resolved My Space root; `--extra` body shape confirmed against the live API (docx copy with `target_type=docx`) | | ✓ | drive +delete | shortcut | drive_delete_dryrun_test.go::TestDriveDeleteDryRunAsyncParams + drive_delete_workflow_test.go::TestDrive_DeleteAsyncWorkflow | `--file-token`; `--type`; fixed query `async=true`; `task_check` follow-up | dry-run locks async request shape; live workflow covers docx, empty folder, and non-empty folder deletion with async/sync/transient-failure convergence | | ✕ | drive +download | shortcut | | none | no file fixture workflow yet | | ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet | @@ -63,7 +66,7 @@ | ✕ | drive file.comments patch | api | | none | no file comment workflow yet | | ✕ | drive file.statistics get | api | | none | no statistics workflow yet | | ✕ | drive file.view_records list | api | | none | no view-record workflow yet | -| ✕ | drive files copy | api | | none | no file copy workflow yet | +| ✕ | drive files copy | api | | none | endpoint exercised live through `drive +copy` (TestDrive_CopyWorkflow); the raw service command itself has no workflow | | ✓ | drive files create_folder | api | drive_files_workflow_test.go::TestDrive_FilesCreateFolderWorkflow/create_folder as bot | `name`; empty `folder_token` in `--data` | | | ✕ | drive files list | api | | none | no list workflow yet | | ✕ | drive metas batch_query | api | | none | no metadata workflow yet | diff --git a/tests/cli_e2e/drive/drive_copy_dryrun_test.go b/tests/cli_e2e/drive/drive_copy_dryrun_test.go new file mode 100644 index 0000000000..b6cef98385 --- /dev/null +++ b/tests/cli_e2e/drive/drive_copy_dryrun_test.go @@ -0,0 +1,153 @@ +// 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" +) + +func TestDriveCopyDryRun_DocxURL(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+copy", + "--url", "https://example.larksuite.com/docx/docxDryRunCopy?from=share", + "--name", "Copied doc", + "--folder-token", "https://example.larksuite.com/drive/folder/folderDryRunCopy", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "POST" { + t.Fatalf("api.0.method=%q, want POST\nstdout:\n%s", got, out) + } + if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/docxDryRunCopy/copy" { + t.Fatalf("api.0.url=%q, want copy endpoint\nstdout:\n%s", got, out) + } + if got := clie2e.DryRunGet(out, "api.0.body.name").String(); got != "Copied doc" { + t.Fatalf("api.0.body.name=%q, want Copied doc\nstdout:\n%s", got, out) + } + if got := clie2e.DryRunGet(out, "api.0.body.type").String(); got != "docx" { + t.Fatalf("api.0.body.type=%q, want docx\nstdout:\n%s", got, out) + } + if got := clie2e.DryRunGet(out, "api.0.body.folder_token").String(); got != "folderDryRunCopy" { + t.Fatalf("api.0.body.folder_token=%q, want token parsed from folder URL\nstdout:\n%s", got, out) + } +} + +func TestDriveCopyDryRun_BareTokenBaseAlias(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+copy", + "--token", "bitableDryRunCopy", + "--type", "base", + "--name", "Copied base", + "--folder-token", "folderDryRunCopy", + "--extra", "target_type=bitable", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/bitableDryRunCopy/copy" { + t.Fatalf("api.0.url=%q, want copy endpoint\nstdout:\n%s", got, out) + } + if got := clie2e.DryRunGet(out, "api.0.body.type").String(); got != "bitable" { + t.Fatalf("api.0.body.type=%q, want bitable (base alias normalized)\nstdout:\n%s", got, out) + } + if got := clie2e.DryRunGet(out, "api.0.body.extra.0.key").String(); got != "target_type" { + t.Fatalf("api.0.body.extra.0.key=%q, want target_type\nstdout:\n%s", got, out) + } + if got := clie2e.DryRunGet(out, "api.0.body.extra.0.value").String(); got != "bitable" { + t.Fatalf("api.0.body.extra.0.value=%q, want bitable\nstdout:\n%s", got, out) + } +} + +func TestDriveCopyDryRun_MySpaceTarget(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+copy", + "--url", "https://example.larksuite.com/docx/docxDryRunCopy", + "--name", "Copied doc", + "--folder-token", "my_space", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + out := result.Stdout + if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "GET" { + t.Fatalf("api.0.method=%q, want GET\nstdout:\n%s", got, out) + } + if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/explorer/v2/root_folder/meta" { + t.Fatalf("api.0.url=%q, want root folder meta\nstdout:\n%s", got, out) + } + if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/docxDryRunCopy/copy" { + t.Fatalf("api.1.url=%q, want copy endpoint\nstdout:\n%s", got, out) + } + if got := clie2e.DryRunGet(out, "api.1.body.folder_token").String(); got != "" { + t.Fatalf("api.1.body.folder_token=%q, want placeholder\nstdout:\n%s", got, out) + } +} + +func TestDriveCopyDryRun_WikiURLRedirectsToWikiNodeCopy(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+copy", + "--url", "https://example.larksuite.com/wiki/wikiDryRunCopy", + "--name", "Copied wiki", + "--folder-token", "folderDryRunCopy", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + if result.ExitCode == 0 { + t.Fatalf("wiki URL should be rejected with a redirect error\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stderr, `"type": "validation"`) { + t.Fatalf("stderr should carry a typed validation error\nstderr:\n%s", result.Stderr) + } + if !strings.Contains(result.Stderr, "wiki +node-copy --space-id") || + !strings.Contains(result.Stderr, "--node-token wikiDryRunCopy") { + t.Fatalf("stderr should guide to wiki +node-copy with the parsed node token\nstderr:\n%s", result.Stderr) + } + if !strings.Contains(result.Stderr, "wiki +node-get --token wikiDryRunCopy") { + t.Fatalf("stderr should explain how to resolve the space id\nstderr:\n%s", result.Stderr) + } +} diff --git a/tests/cli_e2e/drive/drive_copy_workflow_test.go b/tests/cli_e2e/drive/drive_copy_workflow_test.go new file mode 100644 index 0000000000..8cb615b884 --- /dev/null +++ b/tests/cli_e2e/drive/drive_copy_workflow_test.go @@ -0,0 +1,138 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDrive_CopyWorkflow(t *testing.T) { + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-copy-"+suffix, "") + workDir := t.TempDir() + + scheduleDelete := func(fileToken string) { + t.Helper() + if fileToken == "" { + return + } + parentT.Cleanup(func() { + cleanupCtx, cleanupCancel := clie2e.CleanupContext() + defer cleanupCancel() + + deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{ + Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"}, + DefaultAs: "bot", + }, clie2e.RetryOptions{}) + clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr) + }) + } + + sourceContent := "drive copy e2e: source content\n" + sourcePath := filepath.Join(workDir, "copy-source.txt") + if err := os.WriteFile(sourcePath, []byte(sourceContent), 0o644); err != nil { + t.Fatalf("write source file: %v", err) + } + + uploadResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+upload", + "--file", "copy-source.txt", + "--folder-token", folderToken, + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + uploadResult.AssertExitCode(t, 0) + uploadResult.AssertStdoutStatus(t, true) + sourceToken := gjson.Get(uploadResult.Stdout, "data.file_token").String() + require.NotEmpty(t, sourceToken, "uploaded source should have a token, stdout:\n%s", uploadResult.Stdout) + scheduleDelete(sourceToken) + + copyResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+copy", + "--token", sourceToken, + "--type", "file", + "--name", "copy-result.txt", + "--folder-token", folderToken, + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + copyResult.AssertExitCode(t, 0) + copyResult.AssertStdoutStatus(t, true) + + copiedToken := gjson.Get(copyResult.Stdout, "data.file_token").String() + require.NotEmpty(t, copiedToken, "copy should return the new file token, stdout:\n%s", copyResult.Stdout) + scheduleDelete(copiedToken) + if copiedToken == sourceToken { + t.Fatalf("copied token should differ from source token %q\nstdout:\n%s", sourceToken, copyResult.Stdout) + } + if got := gjson.Get(copyResult.Stdout, "data.name").String(); got != "copy-result.txt" { + t.Fatalf("data.name=%q, want copy-result.txt\nstdout:\n%s", got, copyResult.Stdout) + } + if got := gjson.Get(copyResult.Stdout, "data.file_type").String(); got != "file" { + t.Fatalf("data.file_type=%q, want file\nstdout:\n%s", got, copyResult.Stdout) + } + if got := gjson.Get(copyResult.Stdout, "data.source_file_token").String(); got != sourceToken { + t.Fatalf("data.source_file_token=%q, want %q\nstdout:\n%s", got, sourceToken, copyResult.Stdout) + } + + downloadResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+download", + "--file-token", copiedToken, + "--output", "copy-downloaded.txt", + "--overwrite", + }, + WorkDir: workDir, + DefaultAs: "bot", + }) + require.NoError(t, err) + downloadResult.AssertExitCode(t, 0) + downloadResult.AssertStdoutStatus(t, true) + + mySpaceResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+copy", + "--token", sourceToken, + "--type", "file", + "--name", "copy-result-my-space.txt", + "--folder-token", "my_space", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + mySpaceResult.AssertExitCode(t, 0) + mySpaceResult.AssertStdoutStatus(t, true) + + mySpaceToken := gjson.Get(mySpaceResult.Stdout, "data.file_token").String() + require.NotEmpty(t, mySpaceToken, "my_space copy should return the new file token, stdout:\n%s", mySpaceResult.Stdout) + scheduleDelete(mySpaceToken) + if got := gjson.Get(mySpaceResult.Stdout, "data.folder_token").String(); got == "" || got == "my_space" { + t.Fatalf("data.folder_token=%q, want the resolved My Space root token\nstdout:\n%s", got, mySpaceResult.Stdout) + } + + data, err := os.ReadFile(filepath.Join(workDir, "copy-downloaded.txt")) + if err != nil { + t.Fatalf("read downloaded copy: %v", err) + } + if string(data) != sourceContent { + t.Fatalf("copied content=%q want %q", string(data), sourceContent) + } +}