Skip to content
355 changes: 355 additions & 0 deletions shortcuts/drive/drive_pull.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,355 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT

package drive

import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"

larkcore "github.com/larksuite/oapi-sdk-go/v3/core"

"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)

const (
drivePullIfExistsOverwrite = "overwrite"
drivePullIfExistsSkip = "skip"
drivePullListPageSize = 200
drivePullFileType = "file"
drivePullFolderType = "folder"
)

type drivePullItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
}

// DrivePull mirrors a Drive folder onto a local directory: recursively lists
// --folder-token, downloads each type=file entry under --local-dir, and
// optionally deletes local files absent from Drive (--delete-local --yes).
//
// Only Drive entries with type=file participate; online docs (docx, sheet,
// bitable, mindnote, slides) and shortcuts are skipped because there is no
// equivalent local binary to write back.
var DrivePull = common.Shortcut{
Service: "drive",
Command: "+pull",
Description: "Mirror a Drive folder onto a local directory (Drive → local)",
Risk: "write",
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "source Drive folder token", Required: true},
{Name: "if-exists", Desc: "policy when a local file already exists", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}},
{Name: "delete-local", Type: "bool", Desc: "delete local files absent from Drive (mirror semantics); requires --yes"},
{Name: "yes", Type: "bool", Desc: "confirm --delete-local before deleting local files"},
},
Tips: []string{
"Only entries with type=file are downloaded; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
"Subfolders recurse and are reproduced as local directories under --local-dir; missing parents are created automatically.",
"--delete-local requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
if localDir == "" {
return common.FlagErrorf("--local-dir is required")

Check warning on line 68 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L68

Added line #L68 was not covered by tests
}
if folderToken == "" {
return common.FlagErrorf("--folder-token is required")

Check warning on line 71 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L71

Added line #L71 was not covered by tests
}
if err := validate.ResourceName(folderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)

Check warning on line 74 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L74

Added line #L74 was not covered by tests
}
if _, err := validate.SafeLocalFlagPath("--local-dir", localDir); err != nil {
return output.ErrValidation("%s", err)
}
info, err := runtime.FileIO().Stat(localDir)
if err != nil {
return common.WrapInputStatError(err)

Check warning on line 81 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L81

Added line #L81 was not covered by tests
}
if !info.IsDir() {
return output.ErrValidation("--local-dir is not a directory: %s", localDir)

Check warning on line 84 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L84

Added line #L84 was not covered by tests
}
if runtime.Bool("delete-local") && !runtime.Bool("yes") {
return output.ErrValidation("--delete-local requires --yes (high-risk: deletes local files absent from Drive)")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("Recursively list --folder-token, download each type=file entry into --local-dir, and (when --delete-local --yes is set) remove local files absent from Drive.").
GET("/open-apis/drive/v1/files").
Set("folder_token", runtime.Str("folder-token"))
},

Check warning on line 96 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L91-L96

Added lines #L91 - L96 were not covered by tests
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
localDir := strings.TrimSpace(runtime.Str("local-dir"))
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
ifExists := strings.TrimSpace(runtime.Str("if-exists"))
if ifExists == "" {
ifExists = drivePullIfExistsOverwrite

Check warning on line 102 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L102

Added line #L102 was not covered by tests
}
deleteLocal := runtime.Bool("delete-local")

// Resolve --local-dir to its canonical absolute path before we
// touch the filesystem. SafeInputPath fully evaluates symlinks
// across the entire path; this matters because filepath.Clean
// alone shrinks "link/.." to "." while the kernel resolves it
// through the symlink target's parent — meaning a raw walk on
// the user-supplied string can land outside cwd. Walking the
// canonical root sidesteps that, and using cwd canonical lets
// us emit cwd-relative download targets that FileIO.Save's
// SafeOutputPath check still accepts. The risk is much higher
// here than in +status because --delete-local would otherwise
// remove the wrong files outside cwd.
safeRoot, err := validate.SafeInputPath(localDir)
if err != nil {
return output.ErrValidation("--local-dir: %s", err)

Check warning on line 119 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L119

Added line #L119 was not covered by tests
}
cwdCanonical, err := validate.SafeInputPath(".")
if err != nil {
return output.ErrValidation("could not resolve cwd: %s", err)

Check warning on line 123 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L123

Added line #L123 was not covered by tests
}
// rootRelToCwd is the localDir form FileIO.Save accepts (it
// rejects absolute paths). For cwd itself it becomes ".", which
// joins cleanly with the rel_paths returned by the lister.
rootRelToCwd, err := filepath.Rel(cwdCanonical, safeRoot)
if err != nil {
return output.ErrValidation("--local-dir resolves outside cwd: %s", err)

Check warning on line 130 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L130

Added line #L130 was not covered by tests
}

fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
remoteFiles, remotePaths, err := drivePullListRemote(ctx, runtime, folderToken, "")
if err != nil {
return err

Check warning on line 136 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L136

Added line #L136 was not covered by tests
}

var downloaded, skipped, failed, deletedLocal int
items := make([]drivePullItem, 0)

// Deterministic iteration order for output stability.
downloadablePaths := make([]string, 0, len(remoteFiles))
for p := range remoteFiles {
downloadablePaths = append(downloadablePaths, p)
}
sort.Strings(downloadablePaths)

for _, rel := range downloadablePaths {
token := remoteFiles[rel]
target := filepath.Join(rootRelToCwd, rel)

if _, statErr := runtime.FileIO().Stat(target); statErr == nil {
if ifExists == drivePullIfExistsSkip {
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "skipped"})
skipped++
continue
}
}

if err := drivePullDownload(ctx, runtime, token, target); err != nil {
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "failed", Error: err.Error()})
failed++
continue

Check warning on line 164 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L162-L164

Added lines #L162 - L164 were not covered by tests
}
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "downloaded"})
downloaded++
}

if deleteLocal {
// Walk the canonical absolute root, build the list of
// rel_paths, then delete via the absolute path. Both
// values come from the validated safeRoot, so kernel
// path resolution cannot redirect the delete to a file
// outside the canonical subtree.
localAbsPaths, err := drivePullWalkLocal(safeRoot)
if err != nil {
return err

Check warning on line 178 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L178

Added line #L178 was not covered by tests
}
for _, absPath := range localAbsPaths {
rel, relErr := filepath.Rel(safeRoot, absPath)
if relErr != nil {
items = append(items, drivePullItem{RelPath: absPath, Action: "delete_failed", Error: relErr.Error()})
failed++
continue

Check warning on line 185 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L183-L185

Added lines #L183 - L185 were not covered by tests
}
rel = filepath.ToSlash(rel)
// Consult remotePaths (every Drive entry, regardless of
// type) rather than remoteFiles (downloadable subset
// only). Otherwise an online doc / shortcut at e.g.
// "notes.docx" would leave a same-named local file
// looking orphaned and get unlinked even though Drive
// still knows about that path.
if _, ok := remotePaths[rel]; ok {
continue
}
// FileIO has no Remove(); the absolute path comes from
// walking safeRoot, which validate.SafeInputPath has
// already bounded inside cwd, so a bare os.Remove is
// acceptable here. Shortcuts cannot import internal/vfs
// directly (depguard rule shortcuts-no-vfs).
if err := os.Remove(absPath); err != nil { //nolint:forbidigo // see comment above
items = append(items, drivePullItem{RelPath: rel, Action: "delete_failed", Error: err.Error()})
failed++
continue
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
items = append(items, drivePullItem{RelPath: rel, Action: "deleted_local"})
deletedLocal++
}
}

runtime.Out(map[string]interface{}{
"summary": map[string]interface{}{
"downloaded": downloaded,
"skipped": skipped,
"failed": failed,
"deleted_local": deletedLocal,
},
"items": items,
}, nil)
return nil
},
}

// drivePullListRemote recursively lists a Drive folder.
//
// It returns two views:
// - files: rel_path → file_token, for entries with type=file. This is
// the "downloadable" subset and drives the download/skip loop.
// - allPaths: every entry's rel_path regardless of type (file, online doc,
// shortcut, …). --delete-local consults this set so that a local file
// sitting at the same rel_path as e.g. an online doc is NOT treated as
// orphaned and deleted.
//
// Subfolders recurse; online docs and shortcuts are not added to files
// (no equivalent local binary) but are recorded in allPaths.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
//
// TODO(post-#692): when drive +status merges, lift this and the matching
// helper in drive_status.go into a shared listRemoteFolderFiles in the
// drive package.
func drivePullListRemote(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]string, map[string]struct{}, error) {
files := make(map[string]string)
allPaths := make(map[string]struct{})
pageToken := ""
for {
params := map[string]interface{}{
"folder_token": folderToken,
"page_size": fmt.Sprint(drivePullListPageSize),
}
if pageToken != "" {
params["page_token"] = pageToken

Check warning on line 251 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L251

Added line #L251 was not covered by tests
}
result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil)
if err != nil {
return nil, nil, err

Check warning on line 255 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L255

Added line #L255 was not covered by tests
}
rawFiles, _ := result["files"].([]interface{})
for _, item := range rawFiles {
f, ok := item.(map[string]interface{})
if !ok {
continue

Check warning on line 261 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L261

Added line #L261 was not covered by tests
}
fType := common.GetString(f, "type")
fName := common.GetString(f, "name")
fToken := common.GetString(f, "token")
if fName == "" || fToken == "" {
continue

Check warning on line 267 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L267

Added line #L267 was not covered by tests
}
rel := drivePullJoinRel(relBase, fName)
switch fType {
case drivePullFileType:
files[rel] = fToken
allPaths[rel] = struct{}{}
case drivePullFolderType:
subFiles, subPaths, err := drivePullListRemote(ctx, runtime, fToken, rel)
if err != nil {
return nil, nil, err

Check warning on line 277 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L277

Added line #L277 was not covered by tests
}
for k, v := range subFiles {
files[k] = v
}
for k := range subPaths {
allPaths[k] = struct{}{}
}
default:
// docx, sheet, bitable, mindnote, slides, shortcut, …
// — not downloadable, but Drive still owns this rel_path
// so --delete-local must not treat a local same-named
// file as orphaned.
allPaths[rel] = struct{}{}
}
}
hasMore, _ := result["has_more"].(bool)
nextToken := common.GetString(result, "next_page_token")
if !hasMore || nextToken == "" {
break
}
pageToken = nextToken

Check warning on line 298 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L298

Added line #L298 was not covered by tests
}
return files, allPaths, nil
}

func drivePullJoinRel(base, name string) string {
if base == "" {
return name
}
return base + "/" + name
}

func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target string) error {
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
HttpMethod: "GET",
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
})
if err != nil {
return output.ErrNetwork("download %s: %s", common.MaskToken(fileToken), err)

Check warning on line 316 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L316

Added line #L316 was not covered by tests
}
defer resp.Body.Close()
if _, err := runtime.FileIO().Save(target, fileio.SaveOptions{
ContentType: resp.Header.Get("Content-Type"),
ContentLength: resp.ContentLength,
}, resp.Body); err != nil {
return common.WrapSaveErrorByCategory(err, "io")

Check warning on line 323 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L323

Added line #L323 was not covered by tests
}
return nil
}

// drivePullWalkLocal walks the canonical absolute root and returns the
// absolute paths of every regular file underneath it. The caller deletes
// some of these paths, so it is critical that they are produced by
// walking a canonical root (no symlinks in the path) — otherwise OS path
// resolution could redirect a delete to a file outside cwd. Same threat
// model as drive_status.go.
func drivePullWalkLocal(root string) ([]string, error) {
var paths []string
// FileIO has no walker today; shortcuts cannot import internal/vfs
// (depguard rule shortcuts-no-vfs). The root passed in is the
// canonical absolute path returned by validate.SafeInputPath, so
// WalkDir's default "do not follow child symlinks" policy keeps the
// traversal inside the validated subtree.
err := filepath.WalkDir(root, func(absPath string, d fs.DirEntry, walkErr error) error { //nolint:forbidigo // see comment above
if walkErr != nil {
return walkErr

Check warning on line 343 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L343

Added line #L343 was not covered by tests
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
paths = append(paths, absPath)
return nil
})
if err != nil {
return nil, output.Errorf(output.ExitInternal, "io", "walk %s: %s", root, err)

Check warning on line 352 in shortcuts/drive/drive_pull.go

View check run for this annotation

Codecov / codecov/patch

shortcuts/drive/drive_pull.go#L352

Added line #L352 was not covered by tests
}
return paths, nil
}
Loading
Loading