-
Notifications
You must be signed in to change notification settings - Fork 590
feat(drive): add +pull shortcut for one-way Drive → local mirror #696
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
fangshuyu-768
wants to merge
7
commits into
main
Choose a base branch
from
feat/drive-pull
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
7ac869e
feat(drive): add +pull shortcut to mirror a Drive folder onto local
fangshuyu-768 b2d50c1
docs(skills): document drive +pull in lark-drive skill
fangshuyu-768 145c6a2
fix(drive): walk +pull on canonical absolute root to close symlink/..…
fangshuyu-768 0d8fb90
test(drive): pin walker / download behavior on +pull symlink corner c…
fangshuyu-768 45fe4e3
fix(drive): +pull --delete-local must not unlink local files shadowed…
fangshuyu-768 8e5adfd
fix(drive): count +pull delete failures in summary.failed
fangshuyu-768 c1b0bed
fix(drive): swap +pull walk/remove back to filepath/os to satisfy dep…
fangshuyu-768 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | ||
| } | ||
| if folderToken == "" { | ||
| return common.FlagErrorf("--folder-token is required") | ||
| } | ||
| if err := validate.ResourceName(folderToken, "--folder-token"); err != nil { | ||
| return output.ErrValidation("%s", err) | ||
| } | ||
| 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) | ||
| } | ||
| if !info.IsDir() { | ||
| return output.ErrValidation("--local-dir is not a directory: %s", localDir) | ||
| } | ||
| 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")) | ||
| }, | ||
| 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 | ||
| } | ||
| 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) | ||
| } | ||
| cwdCanonical, err := validate.SafeInputPath(".") | ||
| if err != nil { | ||
| return output.ErrValidation("could not resolve cwd: %s", err) | ||
| } | ||
| // 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) | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
| 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 | ||
| } | ||
| 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 | ||
| } | ||
| 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 | ||
| } | ||
| 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. | ||
|
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 | ||
| } | ||
| result, err := runtime.CallAPI("GET", "/open-apis/drive/v1/files", params, nil) | ||
| if err != nil { | ||
| return nil, nil, err | ||
| } | ||
| rawFiles, _ := result["files"].([]interface{}) | ||
| for _, item := range rawFiles { | ||
| f, ok := item.(map[string]interface{}) | ||
| if !ok { | ||
| continue | ||
| } | ||
| fType := common.GetString(f, "type") | ||
| fName := common.GetString(f, "name") | ||
| fToken := common.GetString(f, "token") | ||
| if fName == "" || fToken == "" { | ||
| continue | ||
| } | ||
| 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 | ||
| } | ||
| 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 | ||
| } | ||
| 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) | ||
| } | ||
| 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") | ||
| } | ||
| 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 | ||
| } | ||
| 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) | ||
| } | ||
| return paths, nil | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.