Files
larksuite__cli/shortcuts/drive/drive_copy.go
caojie0621 8a7fa53355 fix(shortcuts): remove non-actionable stderr progress (#2532)
* fix(shortcuts): remove non-actionable stderr progress

Keep successful shortcut output machine-readable by removing lifecycle, retry, and completion progress from docs, wiki, drive, and shared multipart flows. Preserve actionable warnings, fallbacks, and interactive prompts, and update stderr regression assertions.

* test(wiki): distinguish warnings from progress
2026-09-01 17:53:59 +08:00

444 lines
17 KiB
Go

// 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
WikiToken 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 unwrapped first and the resolved
// resource type is passed through to the same Drive copy API.
var DriveCopy = common.Shortcut{
Service: "drive",
Command: "+copy",
Description: "Copy a Drive resource into a target folder; wiki token/URL sources are unwrapped automatically",
Risk: "write",
Scopes: []string{"docs:document:copy"},
ConditionalScopes: []string{"drive:drive.metadata:readonly", "wiki:node:retrieve"},
ConditionalBotScopes: []string{"drive:drive.metadata:readonly", "wiki:node:retrieve", "docs:permission.member:create"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "recommended: Lark/Feishu document URL, including wiki URLs"},
{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.",
"A wiki URL or --token with --type wiki is resolved through wiki get_node, then its underlying Drive resource is copied.",
"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.",
"In bot mode, the CLI also tries to grant the current CLI user full_access on the new copy; the outcome is reported in the permission_grant output field.",
},
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())
}
d := buildDriveCopyDryRun(spec)
if runtime.IsBot() {
d.Set("post_copy_note", "After the copy succeeds in bot mode, the CLI will also try to grant the current CLI user full_access on the new copy.")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec, err := readDriveCopySpec(runtime)
if err != nil {
return err
}
if spec.Ref.Type == "wiki" {
spec, err = resolveDriveCopyWikiResource(ctx, runtime, spec)
if err != nil {
return err
}
}
folderToken := spec.FolderToken
if spec.FolderMySpace {
folderToken, err = resolveDriveCopyMySpaceRoot(runtime)
if err != nil {
return err
}
}
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
}
copiedToken := common.GetString(data, "file", "token")
if copiedToken == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "drive copy succeeded but returned no file token (data.file.token)")
}
out := buildDriveCopyOutput(runtime, spec, folderToken, data)
copiedType := common.GetString(data, "file", "type")
if copiedType == "" {
copiedType = spec.Ref.Type
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, copiedToken, copiedType); grant != nil {
out["permission_grant"] = grant
}
runtime.Out(out, 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 !driveCopyTypeSupported(refType) {
return driveCopyRef{}, errs.NewValidationError(
errs.SubtypeInvalidArgument,
"unsupported %s resource type %q; drive copy supports doc, docx, sheet, file, mindnote, slides, bitable/base, and wiki documents",
sourceFlag,
refType,
).WithParam(sourceFlag)
}
if err := validate.ResourceName(ref.Token, sourceFlag); err != nil {
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).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, wiki)", sourceFlag).WithParam("--type")
}
if !driveCopyTypeSupported(inputType) {
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --type %q; allowed: doc, docx, sheet, file, mindnote, slides, bitable, base, wiki", inputType).WithParam("--type")
}
if err := validate.ResourceName(raw, sourceFlag); err != nil {
return driveCopyRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam(sourceFlag)
}
return driveCopyRef{Token: raw, Type: inputType, SourceFlag: sourceFlag}, nil
}
func resolveDriveCopyWikiResource(ctx context.Context, runtime *common.RuntimeContext, spec driveCopySpec) (driveCopySpec, error) {
wikiToken := spec.Ref.Token
data, err := driveInspectCallWithRetry(ctx, func() (map[string]interface{}, error) {
return runtime.CallAPITyped(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": wikiToken},
nil,
)
})
if err != nil {
return driveCopySpec{}, driveInspectAnnotateError("resolve_wiki", err)
}
node := common.GetMap(data, "node")
objType := normalizeDriveCopyType(strings.ToLower(strings.TrimSpace(common.GetString(node, "obj_type"))))
objToken := strings.TrimSpace(common.GetString(node, "obj_token"))
if objType == "" || objToken == "" {
return driveCopySpec{}, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"wiki get_node returned incomplete node data (obj_type=%q, obj_token=%q)",
objType,
objToken,
)
}
if !driveCopyAPITypeSupported(objType) {
return driveCopySpec{}, errs.NewValidationError(
errs.SubtypeFailedPrecondition,
"wiki node resolves to unsupported Drive copy type %q",
objType,
).WithParam(spec.Ref.SourceFlag).WithHint("use a wiki node whose underlying type is doc, docx, sheet, file, mindnote, slides, or bitable")
}
if err := validate.ResourceName(objToken, spec.Ref.SourceFlag); err != nil {
return driveCopySpec{}, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"wiki get_node returned an unsafe obj_token: %s",
err,
).WithCause(err)
}
spec.Ref.Token = objToken
spec.Ref.Type = objType
spec.Ref.WikiToken = wikiToken
return spec, nil
}
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")
}
if err := validate.ResourceName(ref.Token, "--folder-token"); err != nil {
return "", false, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).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) {
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")
}
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 {
return normalizeDriveCopyType(docType) == "wiki" || driveCopyAPITypeSupported(docType)
}
func driveCopyAPITypeSupported(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.Ref.Type == "wiki" {
dry := common.NewDryRunAPI().
Desc("Resolve wiki node, then copy its underlying resource into Drive").
GET("/open-apis/wiki/v2/spaces/get_node").
Desc("[1] Resolve the wiki node to its underlying resource").
Params(map[string]interface{}{"token": spec.Ref.Token})
folderToken := spec.FolderToken
copyStep := 2
if spec.FolderMySpace {
dry.GET(driveCopyRootFolderMetaPath).
Desc("[2] Resolve the caller's My Space root folder token")
folderToken = "<root folder token from step 2>"
copyStep = 3
}
resolved := spec
resolved.Ref.Token = "<obj_token from step 1>"
resolved.Ref.Type = "<supported obj_type from step 1>"
return dry.POST("/open-apis/drive/v1/files/<obj_token from step 1>/copy").
Desc(fmt.Sprintf("[%d] Copy the resolved wiki resource into Drive", copyStep)).
Body(buildDriveCopyBody(resolved, folderToken)).
Set("wiki_token", spec.Ref.Token).
Set("wiki_source_constraint", "obj_type must be supported by Drive copy")
}
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,
}
if spec.Ref.WikiToken != "" {
out["source_wiki_token"] = spec.Ref.WikiToken
}
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
}