Compare commits

...

1 Commits

Author SHA1 Message Date
zchao.007
2c906b9415 feat(drive): add +copy shortcut
Wrap the Drive file-copy endpoint as drive +copy. Accept a document URL
(recommended) or bare token + --type for the source; the target takes a
folder token, a folder URL, or the my_space constant, which resolves the
caller's My Space root folder via the root-folder-meta endpoint (absent
from platform metadata, works for both user and bot). Repeatable --extra
key=value pairs are forwarded verbatim for special copy semantics (e.g.
target_type=docx to convert a legacy doc during copy). Reject wiki
URLs/tokens with a typed validation error whose hint carries a
ready-to-adapt wiki +node-copy command, because a Drive copy of a
wiki-backed document would land in Drive space instead of the wiki tree.

Declare docs:document:copy (the narrowest scope in the endpoint's any-of
set) plus a conditional drive:drive.metadata:readonly for my_space
resolution. Cover the shortcut with unit tests, dry-run e2e and a
self-contained live workflow (upload -> copy -> download-verify ->
my_space copy -> cleanup), and register it in
tests/cli_e2e/drive/coverage.md. Route copy intents in the lark-drive
skill to the shortcut instead of the raw files copy service command.
2026-07-31 17:39:45 +08:00
9 changed files with 1535 additions and 7 deletions

View File

@@ -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 <space-id> --node-token %s --target-space-id <target-space-id> (or --target-parent-node-token); resolve <space-id> 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, "<root folder token from step 1>")).
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
}

View File

@@ -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 != "<root folder token from step 1>" {
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)
}
}

View File

@@ -11,6 +11,7 @@ func Shortcuts() []common.Shortcut {
DriveUpload,
DriveCreateFolder,
DriveCreateShortcut,
DriveCopy,
DriveDownload,
DrivePreview,
DriveCover,

View File

@@ -18,6 +18,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) {
"+upload",
"+create-folder",
"+create-shortcut",
"+copy",
"+download",
"+preview",
"+cover",

View File

@@ -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":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_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 '<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 +<verb> [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 <resource> <method> [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` — 修改文件标题

View File

@@ -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/<DOCX_TOKEN>" --name '副本名称' --folder-token <TARGET_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": "<new_file_token>",
"file_type": "docx",
"name": "副本名称",
"url": "https://example.larksuite.com/docx/<new_file_token>",
"source_file_token": "<source_file_token>",
"source_type": "docx",
"folder_token": "<target_folder_token>"
}
```
## 参考
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [lark-wiki](../../lark-wiki/SKILL.md) -- 知识库节点复制(`wiki +node-copy`
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -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/<token>` 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/<token>``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 `<sent only when obj_type is docx>` 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 |

View File

@@ -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 != "<root folder token from step 1>" {
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)
}
}

View File

@@ -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)
}
}