Compare commits

...

3 Commits

Author SHA1 Message Date
liangshuo-1
4c63198237 chore(release): v1.0.28 (#830)
Change-Id: If8e5170a3abb8ef846fcb7473977e6bf8bc91767
2026-05-11 20:40:32 +08:00
chenxingtong-bytedance
c0fbe54ef6 feat(lark-im): support UAT for forward and add threads.forward (#689)
- Update messages.forward identity to support `user` and `bot`
  - Add threads.forward entry under threads API resources
  - Add forward APIs -> `im:message`, `im:message.send_as_user` scope mapping

Change-Id: I2e33b0d78d72fd067ba3916095479f9b336e7eb9
2026-05-11 19:35:38 +08:00
fangshuyu-768
4ba39ef392 fix(drive): handle duplicate remote sync paths (#803) 2026-05-11 17:51:23 +08:00
21 changed files with 2071 additions and 86 deletions

View File

@@ -2,6 +2,21 @@
All notable changes to this project will be documented in this file.
## [v1.0.28] - 2026-05-11
### Features
- **im**: Support UAT for `messages.forward` and add `threads.forward` (#689)
- **im**: Add flag shortcuts `+flag-create` / `+flag-list` / `+flag-cancel` for message bookmarks (#770)
### Bug Fixes
- **drive**: Handle duplicate remote sync paths (#803)
### Documentation
- **im**: Name `--query` / `--member-ids` in `+chat-search` shortcut row (#812)
## [v1.0.27] - 2026-05-09
### Features
@@ -644,6 +659,7 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.28]: https://github.com/larksuite/cli/releases/tag/v1.0.28
[v1.0.27]: https://github.com/larksuite/cli/releases/tag/v1.0.27
[v1.0.26]: https://github.com/larksuite/cli/releases/tag/v1.0.26
[v1.0.25]: https://github.com/larksuite/cli/releases/tag/v1.0.25

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.27",
"version": "1.0.28",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -0,0 +1,865 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
)
const (
duplicateRemoteFileIDFirst = "example-file-token-first"
duplicateRemoteFileIDSecond = "example-file-token-second"
duplicateRemoteFileIDThird = "example-file-token-third"
duplicateRemoteFolderID = "example-folder-token"
)
func TestDriveStatusFailsOnDuplicateRemoteFiles(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
registerDuplicateRemoteFiles(reg)
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond)
if stdout.String() != "" {
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
}
reg.Verify(t)
}
func TestDrivePullFailsOnDuplicateRemoteFilesBeforeWriting(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
registerDuplicateRemoteFiles(reg)
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond)
if _, statErr := os.Stat(filepath.Join("local", "dup.txt")); !os.IsNotExist(statErr) {
t.Fatalf("duplicate default failure must not write local dup.txt; stat err=%v", statErr)
}
if stdout.String() != "" {
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
}
reg.Verify(t)
}
func TestDrivePullRenameDownloadsDuplicateRemoteFilesWithStableHashSuffix(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
registerDuplicateRemoteFiles(reg)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst + "/download",
Status: 200,
Body: []byte("FIRST"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond + "/download",
Status: 200,
Body: []byte("SECOND"),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "rename",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
renamedRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0)
mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST")
mustReadFile(t, filepath.Join("local", renamedRelPath), "SECOND")
if strings.Contains(renamedRelPath, duplicateRemoteFileIDSecond) {
t.Fatalf("renamed rel_path should not expose raw file token: %s", renamedRelPath)
}
payload := decodeDrivePullStdout(t, stdout.Bytes())
if got := payload.Data.Summary.Downloaded; got != 2 {
t.Fatalf("summary.downloaded = %d, want 2", got)
}
if item := findPullItem(payload.Data.Items, renamedRelPath); item.SourceID == "" || item.FileToken != "" {
t.Fatalf("rename item should emit source_id without file_token, got: %#v", item)
}
assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded")
reg.Verify(t)
}
func TestDrivePullRenameStrengthensSuffixWhenShortHashTargetAlreadyExists(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
shortHashRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0)
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
{"token": duplicateRemoteFileIDThird, "name": shortHashRelPath, "type": "file", "size": 7, "created_time": "3", "modified_time": "3"},
})
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
registerDownload(reg, duplicateRemoteFileIDThird, "THIRD")
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "rename",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
occupied := occupiedRemotePaths([]driveRemoteEntry{
{RelPath: "dup.txt"},
{RelPath: "dup.txt"},
{RelPath: shortHashRelPath},
})
strongerRelPath, err := relPathWithUniqueFileTokenSuffix("dup.txt", duplicateRemoteFileIDSecond, occupied)
if err != nil {
t.Fatalf("relPathWithUniqueFileTokenSuffix: %v", err)
}
if strongerRelPath == shortHashRelPath {
t.Fatalf("expected stronger unique suffix when %q is already occupied", shortHashRelPath)
}
mustReadFile(t, filepath.Join("local", shortHashRelPath), "THIRD")
mustReadFile(t, filepath.Join("local", strongerRelPath), "SECOND")
payload := decodeDrivePullStdout(t, stdout.Bytes())
if got := payload.Data.Summary.Downloaded; got != 3 {
t.Fatalf("summary.downloaded = %d, want 3", got)
}
if item := findPullItem(payload.Data.Items, strongerRelPath); item.SourceID == "" || item.FileToken != "" {
t.Fatalf("rename item should emit source_id without file_token, got: %#v", item)
}
assertPullItemAction(t, stdout.Bytes(), strongerRelPath, "downloaded")
reg.Verify(t)
}
func TestDrivePullRenameAppendsSequenceWhenAllHashSuffixTargetsAreOccupied(t *testing.T) {
fileToken := duplicateRemoteFileIDSecond
tokenHash := stableTokenHash(fileToken)
occupied := map[string]struct{}{
"dup.txt": {},
relPathWithSuffix("dup.txt", "__lark_"+tokenHash[:12]): {},
relPathWithSuffix("dup.txt", "__lark_"+tokenHash[:24]): {},
relPathWithSuffix("dup.txt", "__lark_"+tokenHash): {},
relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_2"): {},
}
got, err := relPathWithUniqueFileTokenSuffix("dup.txt", fileToken, occupied)
if err != nil {
t.Fatalf("relPathWithUniqueFileTokenSuffix: %v", err)
}
want := relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_3")
if got != want {
t.Fatalf("unique rel_path = %q, want %q", got, want)
}
}
func TestRelPathWithUniqueFileTokenSuffixReturnsErrorAfterMaxAttempts(t *testing.T) {
fileToken := duplicateRemoteFileIDSecond
tokenHash := stableTokenHash(fileToken)
occupied := map[string]struct{}{
"dup.txt": {},
}
for _, suffix := range []string{
"__lark_" + tokenHash[:12],
"__lark_" + tokenHash[:24],
"__lark_" + tokenHash,
} {
occupied[relPathWithSuffix("dup.txt", suffix)] = struct{}{}
}
for attempt := 2; attempt <= driveUniqueSuffixMaxSeq; attempt++ {
occupied[relPathWithSuffix("dup.txt", "__lark_"+tokenHash+"_"+strconv.Itoa(attempt))] = struct{}{}
}
_, err := relPathWithUniqueFileTokenSuffix("dup.txt", fileToken, occupied)
if err == nil {
t.Fatal("expected relPathWithUniqueFileTokenSuffix to fail after exhausting all suffix attempts")
}
}
func TestDrivePullNewestChoosesMostRecentDuplicateRemoteFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
registerDuplicateRemoteFiles(reg)
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "newest",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
mustReadFile(t, filepath.Join("local", "dup.txt"), "SECOND")
assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded")
payload := decodeDrivePullStdout(t, stdout.Bytes())
if got := payload.Data.Summary.Downloaded; got != 1 {
t.Fatalf("summary.downloaded = %d, want 1", got)
}
if item := findPullItem(payload.Data.Items, "dup.txt"); item.FileToken != duplicateRemoteFileIDSecond {
t.Fatalf("stdout should surface the chosen newest file token, got: %#v", item)
}
reg.Verify(t)
}
func TestDrivePullOldestChoosesOldestDuplicateRemoteFile(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
registerDuplicateRemoteFiles(reg)
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "oldest",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST")
assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded")
payload := decodeDrivePullStdout(t, stdout.Bytes())
if got := payload.Data.Summary.Downloaded; got != 1 {
t.Fatalf("summary.downloaded = %d, want 1", got)
}
if item := findPullItem(payload.Data.Items, "dup.txt"); item.FileToken != duplicateRemoteFileIDFirst {
t.Fatalf("stdout should surface the chosen oldest file token, got: %#v", item)
}
reg.Verify(t)
}
func TestDrivePullRenameHandlesNestedDuplicateRemoteFilesEndToEnd(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFolderID, "name": "sub", "type": "folder", "created_time": "1", "modified_time": "1"},
})
registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
})
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "rename",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
renamedRelPath := expectedRenamedRelPath("sub/dup.txt", duplicateRemoteFileIDSecond, 12, 0)
mustReadFile(t, filepath.Join("local", "sub", "dup.txt"), "FIRST")
mustReadFile(t, filepath.Join("local", filepath.FromSlash(renamedRelPath)), "SECOND")
assertPullItemAction(t, stdout.Bytes(), "sub/dup.txt", "downloaded")
assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded")
reg.Verify(t)
}
func TestDrivePushFailsOnDuplicateRemoteFilesBeforeUpload(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
registerDuplicateRemoteFiles(reg)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "overwrite",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
assertDuplicateRemotePathError(t, err, "dup.txt", duplicateRemoteFileIDFirst, duplicateRemoteFileIDSecond)
if stdout.String() != "" {
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
}
reg.Verify(t)
}
func TestDrivePullFailsOnRemoteFileFolderConflictEvenWithRename(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
{"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"},
})
registerRemoteListing(reg, duplicateRemoteFolderID, nil)
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "rename",
"--as", "bot",
}, f, stdout)
assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID)
if stdout.String() != "" {
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
}
reg.Verify(t)
}
func TestDrivePushFailsOnRemoteFileFolderConflictEvenWithNewest(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "dup"), []byte("LOCAL"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
{"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"},
})
registerRemoteListing(reg, duplicateRemoteFolderID, nil)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "newest",
"--if-exists", "skip",
"--as", "bot",
}, f, stdout)
assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID)
if stdout.String() != "" {
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
}
reg.Verify(t)
}
func TestDrivePushDeleteRemoteDeletesUnchosenDuplicateSibling(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
registerDuplicateRemoteFiles(reg)
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{},
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "skip",
"--on-duplicate-remote", "newest",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
reg.Verify(t)
}
func TestDrivePushOldestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
registerDuplicateRemoteFiles(reg)
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "dup-oldest-new-token",
"version": "v11",
},
},
}
reg.Register(uploadStub)
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
reg.Register(deleteStub)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "overwrite",
"--on-duplicate-remote", "oldest",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
body := decodeDriveMultipartBody(t, uploadStub)
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDFirst {
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDFirst)
}
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDSecond)
if deleteStub.CapturedHeaders == nil {
t.Fatal("DELETE for the newer duplicate sibling was never issued")
}
reg.Verify(t)
}
func TestDrivePushNewestResolvesNestedDuplicateRemoteFilesEndToEnd(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll(filepath.Join("local", "sub"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "sub", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFolderID, "name": "sub", "type": "folder", "created_time": "1", "modified_time": "1"},
})
registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
})
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "nested-dup-new-token",
"version": "v7",
},
},
}
reg.Register(uploadStub)
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
reg.Register(deleteStub)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "overwrite",
"--on-duplicate-remote", "newest",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
body := decodeDriveMultipartBody(t, uploadStub)
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDSecond {
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDSecond)
}
assertPushItemAction(t, stdout.Bytes(), "sub/dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
if deleteStub.CapturedHeaders == nil {
t.Fatal("DELETE for nested duplicate sibling was never issued")
}
reg.Verify(t)
}
func TestChooseRemoteFileSortsByParsedTimes(t *testing.T) {
files := []driveRemoteEntry{
{FileToken: "token_b", CreatedTime: "9", ModifiedTime: "9"},
{FileToken: "token_a", CreatedTime: "10", ModifiedTime: "10"},
}
gotNewest, err := chooseRemoteFile(files, driveDuplicateRemoteNewest)
if err != nil {
t.Fatalf("chooseRemoteFile newest: %v", err)
}
if gotNewest.FileToken != "token_a" {
t.Fatalf("newest token = %q, want token_a", gotNewest.FileToken)
}
gotOldest, err := chooseRemoteFile(files, driveDuplicateRemoteOldest)
if err != nil {
t.Fatalf("chooseRemoteFile oldest: %v", err)
}
if gotOldest.FileToken != "token_b" {
t.Fatalf("oldest token = %q, want token_b", gotOldest.FileToken)
}
}
func TestChooseRemoteFileFallsBackToFileTokenOnTimeParseFailure(t *testing.T) {
files := []driveRemoteEntry{
{FileToken: "token_a", CreatedTime: "bad", ModifiedTime: "bad"},
{FileToken: "token_b", CreatedTime: "10", ModifiedTime: "10"},
}
got, err := chooseRemoteFile(files, driveDuplicateRemoteNewest)
if err != nil {
t.Fatalf("chooseRemoteFile: %v", err)
}
if got.FileToken != "token_a" {
t.Fatalf("fallback token = %q, want token_a", got.FileToken)
}
}
func TestChooseRemoteFileRejectsEmptyCandidates(t *testing.T) {
_, err := chooseRemoteFile(nil, driveDuplicateRemoteNewest)
if err == nil {
t.Fatal("expected chooseRemoteFile to reject empty candidates")
}
}
func TestDrivePullRemoteViewsRejectsUnknownStrategy(t *testing.T) {
_, _, err := drivePullRemoteViews([]driveRemoteEntry{
{RelPath: "dup.txt", Type: driveTypeFile, FileToken: duplicateRemoteFileIDFirst},
{RelPath: "dup.txt", Type: driveTypeFile, FileToken: duplicateRemoteFileIDSecond},
}, "mystery")
if err == nil {
t.Fatal("expected drivePullRemoteViews to reject an unknown duplicate strategy")
}
}
func registerDuplicateRemoteFiles(reg *httpmock.Registry) {
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "2", "modified_time": "2"},
})
}
func registerRemoteListing(reg *httpmock.Registry, folderToken string, files []map[string]interface{}) {
items := make([]interface{}, 0, len(files))
for _, file := range files {
items = append(items, file)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=" + folderToken,
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": items,
"has_more": false,
},
},
})
}
func registerDownload(reg *httpmock.Registry, fileToken, body string) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/" + fileToken + "/download",
Status: 200,
Body: []byte(body),
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
})
}
func assertDuplicateRemotePathError(t *testing.T, err error, relPath string, tokens ...string) {
t.Helper()
if err == nil {
t.Fatal("expected duplicate_remote_path error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) {
t.Fatalf("expected *output.ExitError, got %T: %v", err, err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
}
if exitErr.Detail == nil || exitErr.Detail.Type != "duplicate_remote_path" {
t.Fatalf("error detail = %#v, want duplicate_remote_path", exitErr.Detail)
}
detailMap, ok := exitErr.Detail.Detail.(map[string]interface{})
if !ok {
t.Fatalf("duplicate detail type = %T, want map[string]interface{}", exitErr.Detail.Detail)
}
duplicates, ok := detailMap["duplicates_remote"].([]driveDuplicateRemotePath)
if !ok {
t.Fatalf("duplicate detail duplicates_remote type = %T, want []driveDuplicateRemotePath", detailMap["duplicates_remote"])
}
if len(duplicates) == 0 {
t.Fatal("duplicate detail should include at least one rel_path group")
}
if _, hasLegacyFilesKey := detailMap["files"]; hasLegacyFilesKey {
t.Fatalf("duplicate detail should not expose legacy files key: %#v", detailMap)
}
var matched bool
for _, duplicate := range duplicates {
if duplicate.RelPath != relPath {
continue
}
matched = true
if len(duplicate.Entries) != len(tokens) {
t.Fatalf("duplicate entry count = %d, want %d for rel_path %q", len(duplicate.Entries), len(tokens), relPath)
}
for i, token := range tokens {
if duplicate.Entries[i].FileToken != token {
t.Fatalf("duplicate entry %d file_token = %q, want %q", i, duplicate.Entries[i].FileToken, token)
}
if duplicate.Entries[i].Type == "" {
t.Fatalf("duplicate entry %d missing type for rel_path %q", i, relPath)
}
}
}
if !matched {
t.Fatalf("duplicate detail missing rel_path group %q: %#v", relPath, duplicates)
}
raw, marshalErr := json.Marshal(exitErr.Detail.Detail)
if marshalErr != nil {
t.Fatalf("marshal detail: %v", marshalErr)
}
text := string(raw)
if !strings.Contains(text, relPath) {
t.Fatalf("duplicate detail missing rel_path %q: %s", relPath, text)
}
for _, token := range tokens {
if !strings.Contains(text, token) {
t.Fatalf("duplicate detail missing token %q: %s", token, text)
}
}
}
type drivePullStdoutPayload struct {
Data struct {
Summary struct {
Downloaded int `json:"downloaded"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
} `json:"summary"`
Items []struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
SourceID string `json:"source_id,omitempty"`
Action string `json:"action"`
} `json:"items"`
} `json:"data"`
}
func decodeDrivePullStdout(t *testing.T, raw []byte) drivePullStdoutPayload {
t.Helper()
var payload drivePullStdoutPayload
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode pull stdout: %v\n%s", err, string(raw))
}
return payload
}
func findPullItem(items []struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
SourceID string `json:"source_id,omitempty"`
Action string `json:"action"`
}, relPath string) struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
SourceID string `json:"source_id,omitempty"`
Action string `json:"action"`
} {
for _, item := range items {
if item.RelPath == relPath {
return item
}
}
return struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
SourceID string `json:"source_id,omitempty"`
Action string `json:"action"`
}{}
}
func expectedRenamedRelPath(relPath, fileToken string, hashLen, attempt int) string {
sum := sha256.Sum256([]byte(fileToken))
hash := hex.EncodeToString(sum[:])
suffix := "__lark_" + hash[:hashLen]
if attempt > 0 {
suffix = "__lark_" + hash + "_" + strconv.Itoa(attempt)
}
dir, base := path.Split(relPath)
ext := path.Ext(base)
if ext == base {
return dir + base + suffix
}
stem := base[:len(base)-len(ext)]
return dir + stem + suffix + ext
}
func assertPullItemAction(t *testing.T, raw []byte, relPath, action string) {
t.Helper()
var payload struct {
Data struct {
Items []struct {
RelPath string `json:"rel_path"`
Action string `json:"action"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode pull stdout: %v\n%s", err, string(raw))
}
for _, item := range payload.Data.Items {
if item.RelPath == relPath && item.Action == action {
return
}
}
t.Fatalf("missing pull item %q/%q in stdout: %s", relPath, action, string(raw))
}
func assertPushItemAction(t *testing.T, raw []byte, relPath, action, fileToken string) {
t.Helper()
var payload struct {
Data struct {
Items []struct {
RelPath string `json:"rel_path"`
Action string `json:"action"`
FileToken string `json:"file_token"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
t.Fatalf("decode push stdout: %v\n%s", err, string(raw))
}
for _, item := range payload.Data.Items {
if item.RelPath == relPath && item.Action == action && item.FileToken == fileToken {
return
}
}
t.Fatalf("missing push item %q/%q/%q in stdout: %s", relPath, action, fileToken, string(raw))
}

View File

@@ -28,10 +28,17 @@ const (
type drivePullItem struct {
RelPath string `json:"rel_path"`
FileToken string `json:"file_token,omitempty"`
SourceID string `json:"source_id,omitempty"`
Action string `json:"action"`
Error string `json:"error,omitempty"`
}
type drivePullTarget struct {
DownloadToken string
ItemFileToken string
ItemSourceID string
}
// DrivePull performs a one-way file-level mirror from a Drive folder onto
// a local directory: recursively lists --folder-token, downloads each
// type=file entry under --local-dir, and optionally deletes local files
@@ -54,12 +61,14 @@ var DrivePull = common.Shortcut{
{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: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteRename, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
{Name: "delete-local", Type: "bool", Desc: "delete local regular files absent from Drive (file-level mirror; empty directories are NOT pruned); 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.",
"Duplicate remote rel_path conflicts fail by default. Use --on-duplicate-remote=rename to download duplicate files with stable hashed suffixes.",
"--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 {
@@ -102,6 +111,10 @@ var DrivePull = common.Shortcut{
if ifExists == "" {
ifExists = drivePullIfExistsOverwrite
}
duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote"))
if duplicateRemote == "" {
duplicateRemote = driveDuplicateRemoteFail
}
deleteLocal := runtime.Bool("delete-local")
// Resolve --local-dir to its canonical absolute path before we
@@ -132,10 +145,13 @@ var DrivePull = common.Shortcut{
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
if err != nil {
return err
}
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
return duplicateRemotePathError(duplicates)
}
// Two views over the same listing:
// - remoteFiles drives the download/skip loop (only type=file
// has hashable bytes the local mirror can write back).
@@ -143,13 +159,9 @@ var DrivePull = common.Shortcut{
// rel_path Drive owns regardless of type, so a local file
// shadowed by a remote folder / online doc / shortcut is NOT
// treated as orphaned.
remoteFiles := make(map[string]string, len(entries))
remotePaths := make(map[string]struct{}, len(entries))
for rel, entry := range entries {
remotePaths[rel] = struct{}{}
if entry.Type == driveTypeFile {
remoteFiles[rel] = entry.FileToken
}
remoteFiles, remotePaths, err := drivePullRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
}
var downloaded, skipped, failed, deletedLocal int
@@ -164,7 +176,10 @@ var DrivePull = common.Shortcut{
sort.Strings(downloadablePaths)
for _, rel := range downloadablePaths {
token := remoteFiles[rel]
targetFile := remoteFiles[rel]
downloadToken := targetFile.DownloadToken
itemFileToken := targetFile.ItemFileToken
itemSourceID := targetFile.ItemSourceID
target := filepath.Join(rootRelToCwd, rel)
if info, statErr := runtime.FileIO().Stat(target); statErr == nil {
@@ -178,7 +193,8 @@ var DrivePull = common.Shortcut{
if info.IsDir() {
items = append(items, drivePullItem{
RelPath: rel,
FileToken: token,
FileToken: itemFileToken,
SourceID: itemSourceID,
Action: "failed",
Error: fmt.Sprintf("local path is a directory, remote is a regular file: %s", target),
})
@@ -187,19 +203,19 @@ var DrivePull = common.Shortcut{
continue
}
if ifExists == drivePullIfExistsSkip {
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "skipped"})
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, 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()})
if err := drivePullDownload(ctx, runtime, downloadToken, target); err != nil {
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: err.Error()})
failed++
downloadFailed++
continue
}
items = append(items, drivePullItem{RelPath: rel, FileToken: token, Action: "downloaded"})
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "downloaded"})
downloaded++
}
@@ -307,6 +323,66 @@ func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, file
return nil
}
func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]drivePullTarget, map[string]struct{}, error) {
remoteFiles := make(map[string]drivePullTarget, len(entries))
remotePaths := make(map[string]struct{}, len(entries))
fileGroups := make(map[string][]driveRemoteEntry)
occupied := occupiedRemotePaths(entries)
for _, entry := range entries {
if entry.Type == driveTypeFile {
fileGroups[entry.RelPath] = append(fileGroups[entry.RelPath], entry)
continue
}
remotePaths[entry.RelPath] = struct{}{}
}
relPaths := make([]string, 0, len(fileGroups))
for rel := range fileGroups {
relPaths = append(relPaths, rel)
}
sort.Strings(relPaths)
for _, rel := range relPaths {
files := fileGroups[rel]
if len(files) == 1 {
remoteFiles[rel] = drivePullTarget{DownloadToken: files[0].FileToken, ItemFileToken: files[0].FileToken}
remotePaths[rel] = struct{}{}
continue
}
switch duplicateRemote {
case driveDuplicateRemoteRename:
candidates := append([]driveRemoteEntry(nil), files...)
sortRemoteFiles(candidates, driveDuplicateRemoteOldest)
for idx, file := range candidates {
targetRel := rel
if idx > 0 {
var err error
targetRel, err = relPathWithUniqueFileTokenSuffix(rel, file.FileToken, occupied)
if err != nil {
return nil, nil, err
}
}
remoteFiles[targetRel] = drivePullTarget{
DownloadToken: file.FileToken,
ItemSourceID: stableTokenIdentifier(file.FileToken),
}
remotePaths[targetRel] = struct{}{}
}
case driveDuplicateRemoteNewest, driveDuplicateRemoteOldest:
chosen, err := chooseRemoteFile(files, duplicateRemote)
if err != nil {
return nil, nil, err
}
remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken}
remotePaths[rel] = struct{}{}
default:
return nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
}
}
return remoteFiles, remotePaths, 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

View File

@@ -293,6 +293,49 @@ func TestDrivePullPaginationHandlesPageTokenField(t *testing.T) {
reg.Verify(t)
}
func TestDrivePullRenameSummarizesDuplicateDownloadsAndAvoidsRawTokenInRelPath(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
registerDuplicateRemoteFiles(reg)
registerDownload(reg, duplicateRemoteFileIDFirst, "FIRST")
registerDownload(reg, duplicateRemoteFileIDSecond, "SECOND")
err := mountAndRunDrive(t, DrivePull, []string{
"+pull",
"--local-dir", "local",
"--folder-token", "folder_root",
"--on-duplicate-remote", "rename",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
renamedRelPath := expectedRenamedRelPath("dup.txt", duplicateRemoteFileIDSecond, 12, 0)
payload := decodeDrivePullStdout(t, stdout.Bytes())
if got := payload.Data.Summary.Downloaded; got != 2 {
t.Fatalf("summary.downloaded = %d, want 2", got)
}
if out := stdout.String(); strings.Contains(out, duplicateRemoteFileIDSecond) {
t.Fatalf("stdout should not expose the raw duplicate file token in rename mode, got: %s", out)
}
if item := findPullItem(payload.Data.Items, renamedRelPath); item.SourceID == "" || item.FileToken != "" {
t.Fatalf("rename item should emit source_id without file_token, got: %#v", item)
}
mustReadFile(t, filepath.Join("local", "dup.txt"), "FIRST")
mustReadFile(t, filepath.Join("local", renamedRelPath), "SECOND")
assertPullItemAction(t, stdout.Bytes(), "dup.txt", "downloaded")
assertPullItemAction(t, stdout.Bytes(), renamedRelPath, "downloaded")
reg.Verify(t)
}
// TestDrivePullDeleteLocalRequiresYes verifies the upfront safety guard:
// --delete-local without --yes must be rejected before any API call.
func TestDrivePullDeleteLocalRequiresYes(t *testing.T) {

View File

@@ -92,12 +92,14 @@ var DrivePush = common.Shortcut{
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
{Name: "folder-token", Desc: "target Drive folder token", Required: true},
{Name: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (default: skip — safe; opt into overwrite explicitly while the backend version field is rolling out)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSkip}},
{Name: "on-duplicate-remote", Desc: "policy when multiple remote Drive entries map to the same rel_path", Default: driveDuplicateRemoteFail, Enum: []string{driveDuplicateRemoteFail, driveDuplicateRemoteNewest, driveDuplicateRemoteOldest}},
{Name: "delete-remote", Type: "bool", Desc: "delete Drive files absent locally (file-level mirror; remote-only directories are not removed); requires --yes"},
{Name: "yes", Type: "bool", Desc: "confirm --delete-remote before deleting Drive files"},
},
Tips: []string{
"This is a file-level mirror: only type=file entries are uploaded, overwritten or deleted. Online docs (docx, sheet, bitable, mindnote, slides), shortcuts, and remote-only directories are never touched.",
"Local directory structure (including empty directories) is mirrored to Drive via create_folder; existing remote folders are reused.",
"Duplicate remote rel_path conflicts fail by default before upload, overwrite, or delete. Use --on-duplicate-remote=newest|oldest only when the conflict is duplicate files and you explicitly want to target one.",
"Default --if-exists=skip is the safe choice while the upload_all overwrite-version field is rolling out. Pass --if-exists=overwrite to replace remote bytes; on tenants without the field it surfaces a structured api_error and the run exits non-zero.",
"--delete-remote requires --yes; without --yes the command is rejected upfront so a stray flag never deletes anything.",
"--delete-remote --yes also requires the space:document:delete scope. Validate runs a dynamic pre-flight check when the flag is on, so a missing grant fails the run before any upload — preventing a half-synced state where files were uploaded but the cleanup pass cannot delete.",
@@ -164,6 +166,10 @@ var DrivePush = common.Shortcut{
// rolling-out upload_all `file_token`/`version` protocol field.
ifExists = drivePushIfExistsSkip
}
duplicateRemote := strings.TrimSpace(runtime.Str("on-duplicate-remote"))
if duplicateRemote == "" {
duplicateRemote = driveDuplicateRemoteFail
}
deleteRemote := runtime.Bool("delete-remote")
// Resolve --local-dir to its canonical absolute path before walking.
@@ -190,10 +196,13 @@ var DrivePush = common.Shortcut{
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
if err != nil {
return err
}
if duplicates := blockingRemotePathConflicts(entries, duplicateRemote); len(duplicates) > 0 {
return duplicateRemotePathError(duplicates)
}
// Two views over the same listing:
// - remoteFiles drives upload / overwrite / orphan-delete
// decisions (only type=file entries are upload candidates;
@@ -203,15 +212,9 @@ var DrivePush = common.Shortcut{
// path skip create_folder when an intermediate folder already
// exists, and keeps directory recreation idempotent across
// reruns.
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
for rel, entry := range entries {
switch entry.Type {
case driveTypeFile:
remoteFiles[rel] = entry
case driveTypeFolder:
remoteFolders[rel] = entry
}
remoteFiles, remoteFolders, remoteFileGroups, err := drivePushRemoteViews(entries, duplicateRemote)
if err != nil {
return output.Errorf(output.ExitInternal, "internal", "%s", err)
}
var uploaded, skipped, failed, deletedRemote int
@@ -333,24 +336,31 @@ var DrivePush = common.Shortcut{
}
if deleteRemote && !uploadFailed {
// Stable iteration order so failures (and tests) are deterministic.
remoteRelPaths := make([]string, 0, len(remoteFiles))
for p := range remoteFiles {
remoteRelPaths := make([]string, 0, len(remoteFileGroups))
for p := range remoteFileGroups {
remoteRelPaths = append(remoteRelPaths, p)
}
sort.Strings(remoteRelPaths)
for _, rel := range remoteRelPaths {
keepToken := ""
if _, ok := localFiles[rel]; ok {
continue
if chosen, ok := remoteFiles[rel]; ok {
keepToken = chosen.FileToken
}
}
entry := remoteFiles[rel]
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
failed++
continue
for _, entry := range remoteFileGroups[rel] {
if entry.FileToken == keepToken {
continue
}
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "delete_failed", Error: err.Error()})
failed++
continue
}
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
deletedRemote++
}
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "deleted_remote"})
deletedRemote++
}
}
@@ -463,6 +473,46 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil
return files, dirs, nil
}
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {
remoteFiles := make(map[string]driveRemoteEntry, len(entries))
remoteFolders := make(map[string]driveRemoteEntry, len(entries))
fileGroups := make(map[string][]driveRemoteEntry)
for _, entry := range entries {
switch entry.Type {
case driveTypeFile:
fileGroups[entry.RelPath] = append(fileGroups[entry.RelPath], entry)
case driveTypeFolder:
remoteFolders[entry.RelPath] = entry
}
}
relPaths := make([]string, 0, len(fileGroups))
for rel := range fileGroups {
relPaths = append(relPaths, rel)
}
sort.Strings(relPaths)
for _, rel := range relPaths {
files := fileGroups[rel]
if len(files) == 1 {
remoteFiles[rel] = files[0]
continue
}
switch duplicateRemote {
case driveDuplicateRemoteNewest, driveDuplicateRemoteOldest:
chosen, err := chooseRemoteFile(files, duplicateRemote)
if err != nil {
return nil, nil, nil, err
}
remoteFiles[rel] = chosen
default:
return nil, nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
}
}
return remoteFiles, remoteFolders, fileGroups, nil
}
// drivePushEnsureFolder ensures a folder chain (rel_dir relative to the root
// folder identified by rootFolderToken) exists on Drive, creating any
// missing segments via /open-apis/drive/v1/files/create_folder. Returns the

View File

@@ -454,6 +454,124 @@ func TestDrivePushDeleteRemoteSkipsOnlineDocs(t *testing.T) {
}
}
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "dup.txt"), []byte("LOCAL"), 0o644); err != nil {
t.Fatalf("WriteFile: %v", err)
}
registerDuplicateRemoteFiles(reg)
uploadStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"file_token": "dup-new-token",
"version": "v99",
},
},
}
reg.Register(uploadStub)
deleteStub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
reg.Register(deleteStub)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "overwrite",
"--on-duplicate-remote", "newest",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
body := decodeDriveMultipartBody(t, uploadStub)
if got := body.Fields["file_token"]; got != duplicateRemoteFileIDSecond {
t.Fatalf("upload_all form file_token = %q, want %q", got, duplicateRemoteFileIDSecond)
}
out := stdout.String()
if !strings.Contains(out, `"uploaded": 1`) {
t.Fatalf("expected uploaded=1, got: %s", out)
}
if !strings.Contains(out, `"deleted_remote": 1`) {
t.Fatalf("expected deleted_remote=1, got: %s", out)
}
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
if deleteStub.CapturedHeaders == nil {
t.Fatal("DELETE for the unchosen duplicate sibling was never issued")
}
reg.Verify(t)
}
func TestDrivePushDeleteRemoteDeletesEntireDuplicateGroupWithoutLocalCounterpart(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
registerDuplicateRemoteFiles(reg)
deleteFirst := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDFirst,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
deleteSecond := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + duplicateRemoteFileIDSecond,
Body: map[string]interface{}{"code": 0, "msg": "ok"},
}
reg.Register(deleteFirst)
reg.Register(deleteSecond)
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--if-exists", "skip",
"--on-duplicate-remote", "newest",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
}
out := stdout.String()
if !strings.Contains(out, `"uploaded": 0`) {
t.Fatalf("expected uploaded=0, got: %s", out)
}
if !strings.Contains(out, `"deleted_remote": 2`) {
t.Fatalf("expected deleted_remote=2, got: %s", out)
}
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
assertPushItemAction(t, stdout.Bytes(), "dup.txt", "deleted_remote", duplicateRemoteFileIDSecond)
if deleteFirst.CapturedHeaders == nil || deleteSecond.CapturedHeaders == nil {
t.Fatal("expected both duplicate remote DELETE requests to be issued")
}
reg.Verify(t)
}
// TestDrivePushRejectsAbsoluteLocalDir confirms SafeLocalFlagPath surfaces
// the proper flag name in the error message.
func TestDrivePushRejectsAbsoluteLocalDir(t *testing.T) {

View File

@@ -118,19 +118,22 @@ var DriveStatus = common.Shortcut{
}
fmt.Fprintf(runtime.IO().ErrOut, "Listing Drive folder: %s\n", common.MaskToken(folderToken))
entries, err := listRemoteFolder(ctx, runtime, folderToken, "")
entries, err := listRemoteFolderEntries(ctx, runtime, folderToken, "")
if err != nil {
return err
}
if duplicates := duplicateRemoteFilePaths(entries); len(duplicates) > 0 {
return duplicateRemotePathError(duplicates)
}
// +status only diffs binary content, so collapse the unified
// listing to type=file. Online docs / shortcuts have no
// hashable bytes and are intentionally absent from the diff
// view (a docx living next to a same-named local file is a
// known no-op).
remoteFiles := make(map[string]string, len(entries))
for rel, entry := range entries {
for _, entry := range entries {
if entry.Type == driveTypeFile {
remoteFiles[rel] = entry.FileToken
remoteFiles[entry.RelPath] = entry.FileToken
}
}

View File

@@ -213,6 +213,37 @@ func TestDriveStatusPaginatesRemoteListing(t *testing.T) {
reg.Verify(t)
}
func TestDriveStatusFailsOnRemoteFileFolderConflict(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
{"token": duplicateRemoteFileIDFirst, "name": "dup", "type": "file", "size": 5, "created_time": "1", "modified_time": "1"},
{"token": duplicateRemoteFolderID, "name": "dup", "type": "folder", "created_time": "2", "modified_time": "2"},
})
registerRemoteListing(reg, duplicateRemoteFolderID, []map[string]interface{}{
{"token": "nested-file-token", "name": "child.txt", "type": "file", "size": 1, "created_time": "3", "modified_time": "3"},
})
err := mountAndRunDrive(t, DriveStatus, []string{
"+status",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
assertDuplicateRemotePathError(t, err, "dup", duplicateRemoteFileIDFirst, duplicateRemoteFolderID)
if stdout.Len() != 0 {
t.Fatalf("stdout should be empty on duplicate_remote_path, got: %s", stdout.String())
}
reg.Verify(t)
}
func TestDriveStatusRejectsMissingLocalDir(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())

View File

@@ -5,8 +5,14 @@ package drive
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"path"
"sort"
"strconv"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -14,52 +20,63 @@ const (
driveListRemotePageSize = 200
driveTypeFile = "file"
driveTypeFolder = "folder"
driveUniqueSuffixMaxSeq = 1024
)
// driveRemoteEntry is one Drive entry returned by listRemoteFolder. It
// driveRemoteEntry is one Drive entry returned by listRemoteFolderEntries. It
// carries enough metadata for every shortcut that consumes the listing
// to build its own per-shortcut view by filtering on Type.
type driveRemoteEntry struct {
// FileToken is the Drive token for this entry. For type=folder this
// is the folder_token; for everything else it is the file_token.
FileToken string
Name string
Size int64
// Type is the Drive entry kind verbatim from the API:
// "file" | "folder" | "docx" | "doc" | "sheet" | "bitable" |
// "mindnote" | "slides" | "shortcut" | …
Type string
Type string
CreatedTime string
ModifiedTime string
// RelPath is the entry's path relative to the listing root. Encoded
// with "/" separators on every platform so it matches the rel_paths
// produced by the shortcuts' local walkers.
RelPath string
}
// listRemoteFolder recursively lists folderToken under relBase and
// returns one entry per Drive item, keyed by rel_path. Subfolders are
// descended into and the folder's own entry is also recorded — callers
// can reason about "this rel_path is occupied by a folder" without
// re-listing.
type driveDuplicateRemoteEntry struct {
FileToken string `json:"file_token"`
Name string `json:"name"`
Type string `json:"type"`
Size int64 `json:"size,omitempty"`
CreatedTime string `json:"created_time,omitempty"`
ModifiedTime string `json:"modified_time,omitempty"`
}
type driveDuplicateRemotePath struct {
RelPath string `json:"rel_path"`
Entries []driveDuplicateRemoteEntry `json:"entries"`
}
// listRemoteFolderEntries recursively lists folderToken under relBase and
// returns one entry per Drive item. Subfolders are descended into and the
// folder's own entry is also recorded, allowing callers to detect multiple
// remote files that map to the same rel_path.
//
// This is the shared backbone for the three sync-disk shortcuts. None
// of them need every field at every call site, so each one filters
// on Type:
// The helper deliberately stores every Drive object kind. Online docs and
// shortcuts are skipped by sync shortcuts later, but preserving their rel_path
// here prevents destructive mirror modes from treating a local same-named
// regular file as an orphan when Drive already owns that path.
//
// - +status (drive_status.go) keeps Type=="file" and uses FileToken
// to drive content-hash diffs against the local tree.
// - +pull (drive_pull.go) keeps Type=="file" + FileToken for the
// download set, and the full key set (every rel_path) as the
// guard for --delete-local.
// - +push (drive_push.go) keeps Type=="file" + FileToken for upload /
// overwrite / orphan-delete decisions, and Type=="folder" + FileToken
// for the create_folder cache.
//
// Pagination uses common.PaginationMeta, which accepts both
// page_token and next_page_token — the Drive list endpoint has
// historically returned the latter, but the helper future-proofs
// against a backend rename.
func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) (map[string]driveRemoteEntry, error) {
out := make(map[string]driveRemoteEntry)
// Pagination uses common.PaginationMeta, which accepts both page_token and
// next_page_token.
func listRemoteFolderEntries(ctx context.Context, runtime *common.RuntimeContext, folderToken, relBase string) ([]driveRemoteEntry, error) {
var out []driveRemoteEntry
pageToken := ""
for {
if err := ctx.Err(); err != nil {
return nil, err
}
params := map[string]interface{}{
"folder_token": folderToken,
"page_size": fmt.Sprint(driveListRemotePageSize),
@@ -84,15 +101,24 @@ func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folde
continue
}
rel := joinRelDrive(relBase, fName)
out[rel] = driveRemoteEntry{FileToken: fToken, Type: fType, RelPath: rel}
out = append(out, driveRemoteEntry{
FileToken: fToken,
Name: fName,
Size: int64(common.GetFloat(f, "size")),
Type: fType,
CreatedTime: common.GetString(f, "created_time"),
ModifiedTime: common.GetString(f, "modified_time"),
RelPath: rel,
})
if fType == driveTypeFolder {
sub, err := listRemoteFolder(ctx, runtime, fToken, rel)
if err := ctx.Err(); err != nil {
return nil, err
}
sub, err := listRemoteFolderEntries(ctx, runtime, fToken, rel)
if err != nil {
return nil, err
}
for k, v := range sub {
out[k] = v
}
out = append(out, sub...)
}
}
hasMore, nextToken := common.PaginationMeta(result)
@@ -104,6 +130,208 @@ func listRemoteFolder(ctx context.Context, runtime *common.RuntimeContext, folde
return out, nil
}
func duplicateRemoteFilePaths(entries []driveRemoteEntry) []driveDuplicateRemotePath {
groups := make(map[string][]driveRemoteEntry)
for _, entry := range entries {
groups[entry.RelPath] = append(groups[entry.RelPath], entry)
}
relPaths := make([]string, 0, len(groups))
for relPath, grouped := range groups {
if len(grouped) > 1 {
relPaths = append(relPaths, relPath)
}
}
sort.Strings(relPaths)
duplicates := make([]driveDuplicateRemotePath, 0, len(relPaths))
for _, relPath := range relPaths {
grouped := append([]driveRemoteEntry(nil), groups[relPath]...)
sort.SliceStable(grouped, func(i, j int) bool {
if grouped[i].Type != grouped[j].Type {
return grouped[i].Type < grouped[j].Type
}
if cmp, ok := compareDriveTimes(grouped[i].CreatedTime, grouped[j].CreatedTime); ok && cmp != 0 {
return cmp < 0
}
if cmp, ok := compareDriveTimes(grouped[i].ModifiedTime, grouped[j].ModifiedTime); ok && cmp != 0 {
return cmp < 0
}
return grouped[i].FileToken < grouped[j].FileToken
})
dupEntries := make([]driveDuplicateRemoteEntry, 0, len(grouped))
for _, entry := range grouped {
dupEntries = append(dupEntries, driveDuplicateRemoteEntry{
FileToken: entry.FileToken,
Name: entry.Name,
Type: entry.Type,
Size: entry.Size,
CreatedTime: entry.CreatedTime,
ModifiedTime: entry.ModifiedTime,
})
}
duplicates = append(duplicates, driveDuplicateRemotePath{RelPath: relPath, Entries: dupEntries})
}
return duplicates
}
func duplicateRemotePathError(duplicates []driveDuplicateRemotePath) *output.ExitError {
return &output.ExitError{
Code: output.ExitAPI,
Detail: &output.ErrDetail{
Type: "duplicate_remote_path",
Message: "multiple Drive entries map to the same rel_path",
Detail: map[string]interface{}{
"duplicates_remote": duplicates,
},
},
}
}
const (
driveDuplicateRemoteFail = "fail"
driveDuplicateRemoteRename = "rename"
driveDuplicateRemoteNewest = "newest"
driveDuplicateRemoteOldest = "oldest"
)
func sortRemoteFiles(files []driveRemoteEntry, strategy string) {
sort.SliceStable(files, func(i, j int) bool {
a, b := files[i], files[j]
switch strategy {
case driveDuplicateRemoteNewest:
if cmp, ok := compareDriveTimes(a.ModifiedTime, b.ModifiedTime); ok && cmp != 0 {
return cmp > 0
} else if !ok {
return a.FileToken < b.FileToken
}
if cmp, ok := compareDriveTimes(a.CreatedTime, b.CreatedTime); ok && cmp != 0 {
return cmp > 0
} else if !ok {
return a.FileToken < b.FileToken
}
default:
if cmp, ok := compareDriveTimes(a.CreatedTime, b.CreatedTime); ok && cmp != 0 {
return cmp < 0
} else if !ok {
return a.FileToken < b.FileToken
}
if cmp, ok := compareDriveTimes(a.ModifiedTime, b.ModifiedTime); ok && cmp != 0 {
return cmp < 0
} else if !ok {
return a.FileToken < b.FileToken
}
}
return a.FileToken < b.FileToken
})
}
func compareDriveTimes(a, b string) (int, bool) {
av, aErr := strconv.ParseInt(a, 10, 64)
bv, bErr := strconv.ParseInt(b, 10, 64)
if aErr != nil || bErr != nil {
return 0, false
}
switch {
case av < bv:
return -1, true
case av > bv:
return 1, true
default:
return 0, true
}
}
func chooseRemoteFile(files []driveRemoteEntry, strategy string) (driveRemoteEntry, error) {
if len(files) == 0 {
return driveRemoteEntry{}, fmt.Errorf("no Drive entries available for strategy %q", strategy)
}
candidates := append([]driveRemoteEntry(nil), files...)
sortRemoteFiles(candidates, strategy)
return candidates[0], nil
}
func isFileOnlyDuplicatePath(duplicate driveDuplicateRemotePath) bool {
if len(duplicate.Entries) < 2 {
return false
}
for _, entry := range duplicate.Entries {
if entry.Type != driveTypeFile {
return false
}
}
return true
}
func blockingRemotePathConflicts(entries []driveRemoteEntry, duplicateRemote string) []driveDuplicateRemotePath {
duplicates := duplicateRemoteFilePaths(entries)
if duplicateRemote == driveDuplicateRemoteFail {
return duplicates
}
blocking := make([]driveDuplicateRemotePath, 0, len(duplicates))
for _, duplicate := range duplicates {
if !isFileOnlyDuplicatePath(duplicate) {
blocking = append(blocking, duplicate)
}
}
return blocking
}
func occupiedRemotePaths(entries []driveRemoteEntry) map[string]struct{} {
occupied := make(map[string]struct{}, len(entries))
for _, entry := range entries {
occupied[entry.RelPath] = struct{}{}
}
return occupied
}
func stableTokenHash(fileToken string) string {
sum := sha256.Sum256([]byte(fileToken))
return hex.EncodeToString(sum[:])
}
func stableTokenIdentifier(fileToken string) string {
hash := stableTokenHash(fileToken)
if len(hash) > 12 {
hash = hash[:12]
}
return "hash_" + hash
}
func relPathWithSuffix(relPath, suffix string) string {
dir, base := path.Split(relPath)
ext := path.Ext(base)
if ext == base {
return dir + base + suffix
}
stem := base[:len(base)-len(ext)]
return dir + stem + suffix + ext
}
func relPathWithUniqueFileTokenSuffix(relPath, fileToken string, occupied map[string]struct{}) (string, error) {
tokenHash := stableTokenHash(fileToken)
suffixes := []string{
"__lark_" + tokenHash[:12],
"__lark_" + tokenHash[:24],
"__lark_" + tokenHash,
}
for _, suffix := range suffixes {
candidate := relPathWithSuffix(relPath, suffix)
if _, exists := occupied[candidate]; !exists {
occupied[candidate] = struct{}{}
return candidate, nil
}
}
for attempt := 2; attempt <= driveUniqueSuffixMaxSeq; attempt++ {
candidate := relPathWithSuffix(relPath, "__lark_"+tokenHash+"_"+strconv.Itoa(attempt))
if _, exists := occupied[candidate]; !exists {
occupied[candidate] = struct{}{}
return candidate, nil
}
}
return "", fmt.Errorf("could not generate a unique rel_path for %q after %d attempts", relPath, driveUniqueSuffixMaxSeq)
}
// joinRelDrive joins a rel_path base with an entry name using "/".
// Empty base means the entry sits at the listing root. Mirrors the
// behavior the per-shortcut helpers used to ship and keeps rel_paths

View File

@@ -229,8 +229,8 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+upload`](references/lark-drive-upload.md) | Upload a local file to a Drive folder or wiki node |
| [`+create-folder`](references/lark-drive-create-folder.md) | Create a Drive folder, optionally under a parent folder, with bot auto-grant support |
| [`+download`](references/lark-drive-download.md) | Download a file from Drive to local |
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by SHA-256 content hash; reports `new_local` / `new_remote` / `modified` / `unchanged` (read-only diff primitive for sync workflows). `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
| [`+pull`](references/lark-drive-pull.md) | One-way **file-level** mirror of a Drive folder onto a local directory (Drive → local). Supports `--if-exists` (overwrite/skip) and `--delete-local` for orphan cleanup; the destructive `--delete-local` requires `--yes` and only unlinks regular files — empty local directories left behind by remote folder deletes are NOT pruned. Item-level failures exit non-zero (`error.type=partial_failure`) and skip the `--delete-local` pass to avoid half-synced state. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the target is outside cwd. |
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by SHA-256 content hash; reports `new_local` / `new_remote` / `modified` / `unchanged` (read-only diff primitive for sync workflows). Duplicate remote `rel_path` conflicts fail fast with `error.type=duplicate_remote_path` and list every conflicting entry; do not proceed as if one was chosen. `--local-dir` 必须是 cwd 内的相对路径,越界路径 CLI 会直接拒绝;目标在 cwd 外时引导用户切换 agent 工作目录,不要私自 `cd` 绕过。 |
| [`+pull`](references/lark-drive-pull.md) | One-way **file-level** mirror of a Drive folder onto a local directory (Drive → local). Duplicate remote `rel_path` conflicts fail by default before writing; for duplicate files only, `--on-duplicate-remote rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` explicitly choose one. Supports `--if-exists` (overwrite/skip) and `--delete-local` for orphan cleanup; the destructive `--delete-local` requires `--yes` and only unlinks regular files — empty local directories left behind by remote folder deletes are NOT pruned. Item-level failures exit non-zero (`error.type=partial_failure`) and skip the `--delete-local` pass to avoid half-synced state. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the target is outside cwd. |
| [`+create-shortcut`](references/lark-drive-create-shortcut.md) | Create a shortcut to an existing Drive file in another folder |
| [`+add-comment`](references/lark-drive-add-comment.md) | Add a comment to doc/docx/sheet/slides, also supports wiki URL resolving to doc/docx/sheet/slides |
| [`+export`](references/lark-drive-export.md) | Export a doc/docx/sheet/bitable to a local file with limited polling; supports `--file-name` for local naming |
@@ -238,7 +238,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive +<verb> [flags]`
| [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) |
| [`+move`](references/lark-drive-move.md) | Move a file or folder to another location in Drive |
| [`+delete`](references/lark-drive-delete.md) | Delete a Drive file or folder with limited polling for folder deletes |
| [`+push`](references/lark-drive-push.md) | Mirror a local directory onto a Drive folder (local → Drive). Supports `--if-exists` (overwrite/skip) and `--delete-remote` for one-way mirror sync; the destructive `--delete-remote` requires `--yes`. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the source is outside cwd. |
| [`+push`](references/lark-drive-push.md) | Mirror a local directory onto a Drive folder (local → Drive). Duplicate remote `rel_path` conflicts fail by default before upload / overwrite / delete; use `--on-duplicate-remote newest\|oldest` only when the conflict is duplicate files and you explicitly want to target one existing remote file. Supports `--if-exists` (overwrite/skip) and `--delete-remote` for one-way mirror sync; the destructive `--delete-remote` requires `--yes`. `--local-dir` is bounded to cwd by CLI path validation; tell the user to switch the agent's working directory if the source is outside cwd. |
| [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations |
| [`+apply-permission`](references/lark-drive-apply-permission.md) | Apply to the document owner for view/edit access (user-only; 5/day per document) |

View File

@@ -15,10 +15,23 @@
| `summary.skipped` | 按 `--if-exists=skip` 跳过的文件数 |
| `summary.failed` | 下载或写盘失败的文件数 |
| `summary.deleted_local` | 启用 `--delete-local --yes` 时删除的本地文件数 |
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `action` / 失败时的 `error` |
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `source_id` / `action` / 失败时的 `error` |
`summary.failed > 0` 时命令以 **非零状态码**`exit=1``error.type=partial_failure`)退出,且同一份 `summary + items` 会在 `error.detail` 里返回;脚本/agent 直接通过 exit code 判断成败即可,不需要再去解 `summary.failed`
## 远端同名文件冲突
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会下载、覆盖或删除任何本地文件。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
| 策略 | 行为 |
|------|------|
| `fail` | 默认。返回所有冲突条目的完整信息,不写盘 |
| `rename` | 仅适用于 duplicate file。下载全部重复文件第一个保留原名后续文件使用稳定 hash 后缀生成唯一文件名;若短后缀目标已被占用,会自动升级到更强后缀 |
| `newest` | 只下载 `modified_time` 最新的远端文件 |
| `oldest` | 只下载 `created_time` 最早的远端文件 |
`rename` 命名规则稳定且可追溯:`report.pdf` 的后续重复项会落盘为 `report__lark_<hash>.pdf`,例如 `report__lark_3a2f4c5d6e7f.pdf`。如果这个短 hash 目标名已经被同目录下的其他远端对象占用CLI 会自动改用更长的稳定 hash必要时再追加序号后缀直到目标名唯一。此模式下 `items[]` 不再返回可直接复用的 Drive `file_token`CLI 会在 `source_id` 中返回稳定 hash 标识符,供日志、比对和人工排查使用。
## 命令
```bash
@@ -29,6 +42,10 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
--if-exists skip
# 云端有多个同名二进制文件时,显式下载全部并用稳定 hash 后缀改名
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
--on-duplicate-remote rename
# 文件级镜像:下载新文件 + 删除云端没有的本地文件(不删空目录)
# --delete-local 必须搭配 --yes否则会被 Validate 直接拒绝)
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
@@ -42,6 +59,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) |
| `--folder-token` | 是 | string | 源 Drive 文件夹 token |
| `--if-exists` | 否 | enum | 本地文件已存在时的策略:`overwrite`(默认)/ `skip` |
| `--on-duplicate-remote` | 否 | enum | 云端多个条目映射到同一个 `rel_path` 时的策略:`fail`(默认);如果冲突全是 `type=file`,还可选 `rename` / `newest` / `oldest` |
| `--delete-local` | 否 | bool | 删除本地"云端没有的常规文件"**不删空目录**,因此是 file-level mirror**必须配合 `--yes`** |
| `--yes` | 否 | bool | 确认 `--delete-local`;不传时该破坏性操作在 Validate 阶段被拒绝 |
@@ -50,6 +68,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
- **只下载 Drive `type=file` 的二进制文件**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)会被跳过 —— 它们没有等价的本地二进制可写盘,否则会变成产生噪声的"假"下载。
- 子文件夹会递归遍历rel_path 形如 `sub1/sub2/file.txt`,本地缺失的父目录会被自动创建。
- 已存在的本地文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的请自己改名再 pull。
- 云端同名冲突默认失败;只有“冲突全是 `type=file`”且传了 `--on-duplicate-remote rename|newest|oldest` 时才会继续。
## --delete-local 的安全行为
@@ -58,6 +77,7 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
- `--delete-local`(无 `--yes`)→ Validate 直接报错:`--delete-local requires --yes`,没有任何下载、列表请求或删除发生。
- `--delete-local --yes`**且下载阶段全部成功** → 扫一遍 `--local-dir` 下所有常规文件,把不在云端清单里的逐个 `os.Remove`。**只删常规文件,不删目录**:远端文件夹被删除后,对应本地目录会保留空壳。
- `--delete-local --yes`**但下载阶段有任何条目失败** → **跳过整个删除阶段**,命令以 `partial_failure` 非零退出。设计意图:避免出现"前面下载失败、后面继续删本地文件"的半同步状态;操作者修好下载错误后再重跑即可。
- 远端同名文件冲突且使用默认 `fail` → 在下载阶段前失败,删除阶段不会运行。
- 不传 `--delete-local``summary.deleted_local` 永远是 0命令对本地"多余"文件视而不见。
第 6 章里把 `+pull --delete-local` 标了 `high-risk-write`CLI 这边的实现等价于"未传 `--yes` 时拒绝执行",符合该约束的精神。
@@ -74,15 +94,15 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
},
"items": [
{"rel_path": "...", "file_token": "...", "action": "downloaded"},
{"rel_path": "...", "file_token": "...", "action": "skipped"},
{"rel_path": "...", "file_token": "...", "action": "failed", "error": "..."},
{"rel_path": "...", "source_id": "hash_3a2f4c5d6e7f", "action": "downloaded"},
{"rel_path": "...", "source_id": "hash_3a2f4c5d6e7f", "action": "failed", "error": "..."},
{"rel_path": "...", "action": "deleted_local"},
{"rel_path": "...", "action": "delete_failed", "error": "..."}
]
}
```
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。删除条目(`deleted_local` / `delete_failed`)没有 `file_token`,因为该文件本来就只在本地
`rel_path` 始终用 `/` 作为分隔符(跨平台一致)。删除条目(`deleted_local` / `delete_failed`)没有 `file_token``rename` 模式下duplicate 文件条目会返回 `source_id` 而不是可调用 API 的真实 `file_token`;其余模式仍返回真实 `file_token`
## 性能注意

View File

@@ -21,6 +21,18 @@
> 本地目录(包括空目录)会被镜像到 Drive新建的子目录会以 `action: "folder_created"` 出现在 `items[]` 里,但**不计入** `summary.uploaded`(该字段只数文件)。已存在的远端目录复用其 token不会重复 `create_folder`,也不会出现在 `items[]` 里。
## 远端同名文件冲突
如果 Drive 中多个条目映射到同一个 `rel_path`,默认直接失败(`error.type=duplicate_remote_path`),且不会上传、覆盖或进入 `--delete-remote` 删除阶段。只有“多个 `type=file` 同名”的场景支持显式策略;`file-folder` 这类异构冲突始终直接失败。
| 策略 | 行为 |
|------|------|
| `fail` | 默认。返回所有冲突条目的完整信息,不写远端 |
| `newest` | 只把本地文件与 `modified_time` 最新的远端文件对齐 |
| `oldest` | 只把本地文件与 `created_time` 最早的远端文件对齐 |
`+push` 不提供 `rename`:本地一个文件无法表达要覆盖多个远端对象。若用户想保留多个云端副本,应先显式整理云端文件,再重新 push。
## 命令
```bash
@@ -32,6 +44,10 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
--if-exists overwrite
# 云端已有多个同名二进制文件时,显式选择一个远端目标再覆盖
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
--if-exists overwrite --on-duplicate-remote newest
# 文件级镜像同步:上传 / 覆盖 + 删除本地不存在的远端文件
# --delete-remote 必须搭配 --yes否则会被 Validate 直接拒绝;
# 且 Validate 阶段会动态检查 space:document:delete scope缺权限会立刻失败
@@ -47,6 +63,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃出 cwd 的相对路径会被 CLI 直接拒绝) |
| `--folder-token` | 是 | string | 目标 Drive 文件夹 token |
| `--if-exists` | 否 | enum | 远端文件已存在时的策略:`skip`**默认**,安全)/ `overwrite`(依赖灰度后端协议,详见"覆盖语义" |
| `--on-duplicate-remote` | 否 | enum | 云端多个条目映射到同一个 `rel_path` 时的策略:`fail`(默认);如果冲突全是 `type=file`,还可选 `newest` / `oldest` |
| `--delete-remote` | 否 | bool | 删除云端本地不存在的文件(文件级镜像;**不会**清理远端只有的目录);**必须配合 `--yes`**,且 Validate 阶段会动态检查 `space:document:delete` scope |
| `--yes` | 否 | bool | 确认 `--delete-remote`;不传时该破坏性操作在 Validate 阶段被拒绝 |
@@ -55,6 +72,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
- **只上传 / 覆盖 / 删除 Drive `type=file`**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)即使在同一 rel_path 下出现,也不会被覆盖或删除 —— 它们没有等价的本地二进制。
- **本地目录结构整体被镜像**:所有子目录(含**空目录**)会按需在 Drive 上 `create_folder`;同名远端目录复用其 token不重建。空目录不计入 `summary.uploaded`,但会在 `items[]` 里以 `folder_created` 形式留痕。
- 已存在的远端文件按 `--if-exists` 决定 `overwrite` 还是 `skip`,没有第三种选择 —— 想做 `keep-both` 这类的请自行改名再 push。
- 云端同名冲突默认失败;只有“冲突全是 `type=file`”且传了 `--on-duplicate-remote newest|oldest` 时才会选择一个远端文件继续。启用 `--delete-remote` 时,未被选中的 duplicate sibling 也会被删除,最终远端只保留一个被选中的文件副本;只有在 `--if-exists=overwrite` 成功时,才能保证该副本内容与本地对齐。
## 覆盖语义
@@ -71,6 +89,7 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
- `--delete-remote`(无 `--yes`)→ Validate 直接报错:`--delete-remote requires --yes`,不会发起任何列表 / 上传 / 删除请求。
- `--delete-remote --yes` → Validate 阶段还会**动态做一次** `space:document:delete` 的 scope 预检:缺这条 scope 时整次运行立刻失败、不发任何上传请求,避免出现"上传都成功了,但删除阶段才报 missing_scope"的半同步状态。
- `--delete-remote --yes`(且 scope 已授权)→ 正常执行:先把本地文件 push 上去,再扫一遍远端 `type=file` 列表,把不在本地清单里的逐个删除。**任何上传 / 覆盖 / 建目录失败时,整段 `--delete-remote` 阶段会被跳过**stderr 上有提示),命令以非零状态退出,远端不会被破坏。
- 远端同名冲突且使用默认 `fail`,或冲突里混有 folder / 其他非 `type=file` 对象 → 在上传阶段前失败,删除阶段不会运行。
- 不传 `--delete-remote``summary.deleted_remote` 永远是 0命令对远端"多余"文件视而不见。
- 在线文档docx / sheet / bitable / ...)和快捷方式即使本地完全没有同名文件,也**不会**进入删除候选,因为它们从来不进 `summary.uploaded` 的对齐域。
- **远端只有的空目录、本地已删除的目录**也不会被清理 —— 这是"文件级镜像"的语义边界,命令不会对目录结构做主动收敛。

View File

@@ -14,6 +14,10 @@
只读命令:流式 hash不下载落盘但双端都有的文件会从云端拉一份字节流过来在内存里算 hash大目录 / 大文件会有可观的网络流量。
## 远端同名文件冲突
如果 Drive 中多个条目映射到同一个 `rel_path``+status` 会在下载/hash 前直接失败,返回 `error.type=duplicate_remote_path`,并在 `error.detail.duplicates_remote[]` 中列出该路径下所有冲突条目的 `file_token``type`、名称、大小和时间字段;其中 `created_time``modified_time` 缺失时会省略,`size` 在缺失或为 `0` 时都可能被省略。不要把这种情况当成普通 `modified`;它表示同步域本身有歧义,需要先整理云端结构,或在 `+pull` / `+push` 中仅对“duplicate file”场景显式选择冲突策略。
## 命令
```bash
@@ -38,6 +42,8 @@ lark-cli drive +status \
## 输出 schema
成功时:
```json
{
"new_local": [{"rel_path": "..."}],
@@ -49,10 +55,34 @@ lark-cli drive +status \
`rel_path` 始终用 `/` 作为分隔符(跨平台一致),相对于 `--local-dir``--folder-token` 的根。仅本地存在时没有 `file_token` 字段。
远端同名文件冲突时:
```json
{
"ok": false,
"error": {
"type": "duplicate_remote_path",
"message": "multiple Drive entries map to the same rel_path",
"detail": {
"duplicates_remote": [
{
"rel_path": "dup.txt",
"entries": [
{"file_token": "<full_file_token>", "type": "file", "name": "dup.txt", "size": 5, "created_time": "1730000000", "modified_time": "1730000000"},
{"file_token": "<folder_token>", "type": "folder", "name": "dup.txt", "created_time": "1730000060", "modified_time": "1730000060"}
]
}
]
}
}
}
```
## 比较范围
- **只比对 Drive `type=file` 的二进制文件**。在线文档(`docx` / `sheet` / `bitable` / `mindnote` / `slides`)和快捷方式(`shortcut`)都被跳过 —— 它们没有等价的本地二进制可对齐,否则会在 `new_remote` 里产生大量误报。
- 子文件夹会递归遍历rel_path 形如 `sub1/sub2/file.txt`
- 多个远端条目映射到同一个 rel_path 时不做隐式选择,默认失败。
- 本地侧只比对常规文件regular file符号链接、设备文件等被忽略。
## 范围限制

View File

@@ -109,7 +109,7 @@ lark-cli im <resource> <method> [flags] # 调用 API
### messages
- `delete` — 撤回消息。Identity: supports `user` and `bot`; for `bot` calls, the bot must be in the chat to revoke group messages; to revoke another user's group message, the bot must be the owner, an admin, or the creator; for user P2P recalls, the target user must be within the bot's availability.
- `forward` — 转发消息。Identity: `bot` only (`tenant_access_token`).
- `forward` — 转发消息。Identity: supports `user` and `bot`.
- `merge_forward` — 合并转发消息。Identity: `bot` only (`tenant_access_token`).
- `read_users` — 查询消息已读信息。Identity: `bot` only (`tenant_access_token`); the bot must be in the chat, and can only query read status for messages it sent within the last 7 days.
@@ -120,6 +120,10 @@ lark-cli im <resource> <method> [flags] # 调用 API
- `delete` — 删除消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message, and can only delete reactions added by itself.[Must-read](references/lark-im-reactions.md)
- `list` — 获取消息表情回复。Identity: supports `user` and `bot`; the caller must be in the conversation that contains the message.[Must-read](references/lark-im-reactions.md)
### threads
- `forward` — 转发话题。Identity: supports `user` and `bot`.
### images
- `create` — 上传图片。Identity: `bot` only (`tenant_access_token`).
@@ -147,6 +151,7 @@ lark-cli im <resource> <method> [flags] # 调用 API
| `messages.forward` | `im:message` |
| `messages.merge_forward` | `im:message` |
| `messages.read_users` | `im:message:readonly` |
| `threads.forward` | `im:message` |
| `reactions.batch_query` | `im:message.reactions:read` |
| `reactions.create` | `im:message.reactions:write_only` |
| `reactions.delete` | `im:message.reactions:write_only` |

View File

@@ -2,17 +2,20 @@
## Metrics
- Denominator: 29 leaf commands
- Covered: 2
- Coverage: 6.9%
- Covered: 7
- Coverage: 24.1%
## 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`.
- TestDrive_StatusWorkflow: proves `drive +status` against a real Drive folder. Seeds the remote side via `drive +upload` (`unchanged.txt`, `modified.txt`, `remote-only.txt`), seeds local files with the matching/diverging contents, and asserts every output bucket (`unchanged`, `modified`, `new_local`, `new_remote`) holds exactly the expected `rel_path` and `file_token`. Cleans up uploaded files and the parent folder via best-effort cleanup hooks.
- 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 `duplicate_remote_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.
- TestDriveExportDryRun_FileNameMetadata: dry-run coverage for `drive +export`; asserts export task request shape and local `--file-name` / `--output-dir` metadata without calling live APIs.
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
- Blocked area: live upload, live export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` covers the wiki-target request shape for `drive +upload`, but there is still no live upload workflow coverage.
- Blocked area: live export, comment, permission, subscription, and reply flows still need deterministic remote fixtures and filesystem setup.
- Dry-run note: `drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget` covers the wiki-target request shape for `drive +upload`; live duplicate/status workflows also use real `+upload` to seed remote fixtures.
## Command Table
@@ -26,9 +29,11 @@
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
| ✕ | drive +import | shortcut | | none | no import workflow yet |
| ✕ | drive +move | shortcut | | none | no move workflow yet |
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflow seeds via `+upload` and asserts all four buckets |
| ✓ | drive +pull | shortcut | drive_pull_dryrun_test.go::TestDrive_PullDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--on-duplicate-remote=rename\|newest\|oldest`; `--delete-local --yes` guard | dry-run locks flag/validate shape; live workflow proves duplicate fail-fast and rename recovery |
| ✓ | drive +push | shortcut | drive_push_dryrun_test.go::TestDrive_PushDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; `--if-exists`; `--on-duplicate-remote=newest\|oldest`; `--delete-remote --yes` | dry-run locks flag/validate shape; live workflow proves overwrite + duplicate cleanup converges status |
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflows cover both normal hashing buckets and duplicate-remote failure |
| ✕ | drive +task_result | shortcut | | none | no async task-result workflow yet |
| | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget (dry-run only) | `--wiki-token`; `parent_type=wiki`; `parent_node` | no live upload workflow yet |
| | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget + drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--wiki-token`; `parent_type=wiki`; `parent_node`; named uploads into Drive folders | dry-run covers wiki-target shape; live workflows assert returned file tokens and consume the uploaded fixtures |
| ✕ | drive file.comment.replys create | api | | none | no reply workflow yet |
| ✕ | drive file.comment.replys delete | api | | none | no reply workflow yet |
| ✕ | drive file.comment.replys list | api | | none | no reply workflow yet |

View File

@@ -0,0 +1,208 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_DuplicateRemoteWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
t.Cleanup(cancel)
uploadNamedFile := func(t *testing.T, workDir, folderToken, stageName, remoteName, content string) string {
t.Helper()
stagePath := filepath.Join(workDir, stageName)
if err := os.WriteFile(stagePath, []byte(content), 0o644); err != nil {
t.Fatalf("write stage file %s: %v", stageName, err)
}
t.Cleanup(func() { _ = os.Remove(stagePath) })
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+upload",
"--file", stageName,
"--folder-token", folderToken,
"--name", remoteName,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
fileToken := gjson.Get(result.Stdout, "data.file_token").String()
require.NotEmpty(t, fileToken, "uploaded file should have a token, stdout:\n%s", result.Stdout)
return fileToken
}
t.Run("status and pull handle duplicate remote files", func(t *testing.T) {
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-dup-pull-"+suffix, "")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
firstToken := uploadNamedFile(t, workDir, folderToken, "_dup_first.txt", "dup.txt", "first")
secondToken := uploadNamedFile(t, workDir, folderToken, "_dup_second.txt", "dup.txt", "second")
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
if statusResult.ExitCode == 0 {
t.Fatalf("+status should fail on duplicate remote rel_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
}
if !strings.Contains(statusResult.Stderr, `"type": "duplicate_remote_path"`) {
t.Fatalf("+status stderr should contain duplicate_remote_path\nstdout:\n%s\nstderr:\n%s", statusResult.Stdout, statusResult.Stderr)
}
pullFailResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
if pullFailResult.ExitCode == 0 {
t.Fatalf("+pull should fail on duplicate remote rel_path by default\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
}
if !strings.Contains(pullFailResult.Stderr, `"type": "duplicate_remote_path"`) {
t.Fatalf("+pull stderr should contain duplicate_remote_path\nstdout:\n%s\nstderr:\n%s", pullFailResult.Stdout, pullFailResult.Stderr)
}
if _, statErr := os.Stat(filepath.Join(workDir, "local", "dup.txt")); !os.IsNotExist(statErr) {
t.Fatalf("default duplicate failure must not write dup.txt; stat err=%v", statErr)
}
pullRenameResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", folderToken,
"--on-duplicate-remote", "rename",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
pullRenameResult.AssertExitCode(t, 0)
pullRenameResult.AssertStdoutStatus(t, true)
items := gjson.Get(pullRenameResult.Stdout, "data.items")
if items.Array() == nil || len(items.Array()) != 2 {
t.Fatalf("+pull rename should produce two items, stdout:\n%s", pullRenameResult.Stdout)
}
if got := gjson.Get(pullRenameResult.Stdout, "data.summary.downloaded").Int(); got != 2 {
t.Fatalf("+pull rename downloaded=%d, want 2\nstdout:\n%s", got, pullRenameResult.Stdout)
}
relPaths := []string{
gjson.Get(pullRenameResult.Stdout, "data.items.0.rel_path").String(),
gjson.Get(pullRenameResult.Stdout, "data.items.1.rel_path").String(),
}
var renamedRel string
for _, rel := range relPaths {
if rel != "dup.txt" {
renamedRel = rel
}
}
if renamedRel == "" || !strings.HasPrefix(renamedRel, "dup__lark_") || !strings.HasSuffix(renamedRel, ".txt") {
t.Fatalf("renamed rel_path = %q, want dup__lark_<hash>.txt\nstdout:\n%s", renamedRel, pullRenameResult.Stdout)
}
if !strings.Contains(pullRenameResult.Stdout, `"source_id":"hash_`) &&
!strings.Contains(pullRenameResult.Stdout, `"source_id": "hash_`) {
t.Fatalf("+pull rename stdout should contain source_id for duplicate items\nstdout:\n%s", pullRenameResult.Stdout)
}
if strings.Contains(pullRenameResult.Stdout, firstToken) || strings.Contains(pullRenameResult.Stdout, secondToken) {
t.Fatalf("+pull rename stdout should not expose raw duplicate file tokens\nstdout:\n%s", pullRenameResult.Stdout)
}
require.FileExists(t, filepath.Join(workDir, "local", "dup.txt"))
require.FileExists(t, filepath.Join(workDir, "local", filepath.FromSlash(renamedRel)))
})
t.Run("push resolves duplicate remote files and converges status", func(t *testing.T) {
suffix := clie2e.GenerateSuffix()
folderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-dup-push-"+suffix, "")
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("mkdir local: %v", err)
}
if err := os.WriteFile(filepath.Join(workDir, "local", "dup.txt"), []byte("local-overwrite"), 0o644); err != nil {
t.Fatalf("write local dup.txt: %v", err)
}
_ = uploadNamedFile(t, workDir, folderToken, "_push_dup_first.txt", "dup.txt", "remote-first")
time.Sleep(1200 * time.Millisecond)
_ = uploadNamedFile(t, workDir, folderToken, "_push_dup_second.txt", "dup.txt", "remote-second")
pushResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", folderToken,
"--if-exists", "overwrite",
"--on-duplicate-remote", "newest",
"--delete-remote",
"--yes",
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
pushResult.AssertExitCode(t, 0)
pushResult.AssertStdoutStatus(t, true)
if got := gjson.Get(pushResult.Stdout, "data.summary.uploaded").Int(); got != 1 {
t.Fatalf("+push uploaded=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
}
if got := gjson.Get(pushResult.Stdout, "data.summary.deleted_remote").Int(); got != 1 {
t.Fatalf("+push deleted_remote=%d, want 1\nstdout:\n%s", got, pushResult.Stdout)
}
statusResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+status",
"--local-dir", "local",
"--folder-token", folderToken,
},
WorkDir: workDir,
DefaultAs: "bot",
})
require.NoError(t, err)
statusResult.AssertExitCode(t, 0)
statusResult.AssertStdoutStatus(t, true)
if got := gjson.Get(statusResult.Stdout, "data.unchanged.#").Int(); got != 1 {
t.Fatalf("+status unchanged count=%d, want 1\nstdout:\n%s", got, statusResult.Stdout)
}
if got := gjson.Get(statusResult.Stdout, "data.unchanged.0.rel_path").String(); got != "dup.txt" {
t.Fatalf("+status unchanged rel_path=%q, want dup.txt\nstdout:\n%s", got, statusResult.Stdout)
}
if got := gjson.Get(statusResult.Stdout, "data.modified.#").Int(); got != 0 ||
gjson.Get(statusResult.Stdout, "data.new_local.#").Int() != 0 ||
gjson.Get(statusResult.Stdout, "data.new_remote.#").Int() != 0 {
t.Fatalf("+status should converge to a clean unchanged mirror\nstdout:\n%s", statusResult.Stdout)
}
})
}

View File

@@ -171,3 +171,44 @@ func TestDrive_PullDryRunRejectsMissingFolderToken(t *testing.T) {
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
func TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
for _, strategy := range []string{"rename", "newest", "oldest"} {
t.Run(strategy, func(t *testing.T) {
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+pull",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--on-duplicate-remote", strategy,
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
})
}
}

View File

@@ -241,3 +241,44 @@ func TestDrive_PushDryRunRejectsMissingFolderToken(t *testing.T) {
t.Fatalf("expected folder-token in error, got:\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
}
func TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
for _, strategy := range []string{"newest", "oldest"} {
t.Run(strategy, func(t *testing.T) {
workDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+push",
"--local-dir", "local",
"--folder-token", "fldcnE2E001",
"--on-duplicate-remote", strategy,
"--dry-run",
},
WorkDir: workDir,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := gjson.Get(out, "api.0.method").String(); got != "GET" {
t.Fatalf("method = %q, want GET\nstdout:\n%s", got, out)
}
if got := gjson.Get(out, "folder_token").String(); got != "fldcnE2E001" {
t.Fatalf("folder_token = %q, want fldcnE2E001\nstdout:\n%s", got, out)
}
})
}
}

View File

@@ -1,9 +1,9 @@
# IM CLI E2E Coverage
## Metrics
- Denominator: 29 leaf commands
- Covered: 9
- Coverage: 31.0%
- Denominator: 30 leaf commands
- Covered: 11
- Coverage: 36.7%
## Summary
- TestIM_ChatUpdateWorkflow: proves `im +chat-create`, `im +chat-update`, and `im chats get`; key `t.Run(...)` proof points are `update chat name as bot`, `update chat description as bot`, and `get updated chat as bot`.
@@ -12,6 +12,7 @@
- TestIM_ChatMessageWorkflowAsUser: proves the user chat message flow through `create chat as user`, `send message as user`, and `list chat messages as user` with the created message ID and content asserted from read-after-write output.
- TestIM_MessageGetWorkflowAsUser: proves user message readback through `batch get message as user` after creating a fresh chat and sending a unique message.
- TestIM_MessageReplyWorkflowAsBot: proves threaded reply flow through `reply to message in thread as bot` and `list thread replies as bot`, reading back the reply from `im +threads-messages-list`.
- TestIM_MessageForwardWorkflowAsUser: proves UAT-backed API forwarding through `im messages forward` and `im threads forward` using a fresh message/thread fixture; skips the forward assertions when the current test app/UAT lacks IM forward permission.
- Blocked area: `im +chat-search` did not reliably return freshly created private chats in UAT, and `im +messages-search` did not reliably index freshly sent messages in time for a deterministic read-after-write assertion, so both remain uncovered.
## Command Table
@@ -37,9 +38,10 @@
| ✕ | im chats update | api | | none | only covered indirectly through `+chat-update` |
| ✕ | im images create | api | | none | no image upload workflow yet |
| ✕ | im messages delete | api | | none | no recall workflow yet |
| | im messages forward | api | | none | no forward workflow yet |
| | im messages forward | api | im/message_forward_workflow_test.go::TestIM_MessageForwardWorkflowAsUser/forward message with api command as user | `message_id`; `receive_id_type`; `uuid`; `receive_id` | forwards a fresh message back into the test chat using UAT |
| ✕ | im messages merge_forward | api | | none | no merge-forward workflow yet |
| ✕ | im messages read_users | api | | none | no read-user workflow yet |
| ✓ | im threads forward | api | im/message_forward_workflow_test.go::TestIM_MessageForwardWorkflowAsUser/forward thread with api command as user | `thread_id`; `receive_id_type`; `uuid`; `receive_id` | forwards a fresh thread back into the test chat using UAT |
| ✕ | im pins create | api | | none | pin workflows not covered |
| ✕ | im pins delete | api | | none | pin workflows not covered |
| ✕ | im pins list | api | | none | pin workflows not covered |

View File

@@ -0,0 +1,184 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestIM_MessageForwardWorkflowAsUser(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
clie2e.SkipWithoutUserToken(t)
suffix := clie2e.GenerateSuffix()
messageText := "im-forward-msg-" + suffix
replyText := "im-forward-reply-" + suffix
selfOpenID := getSelfOpenID(t, ctx)
chatID, messageID := sendDirectMessageToUser(t, ctx, selfOpenID, messageText, "bot")
t.Run("forward message with api command as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "messages", "forward"},
DefaultAs: "user",
Params: map[string]any{
"message_id": messageID,
"receive_id_type": "chat_id",
"uuid": "msg-forward-" + suffix,
},
Data: map[string]any{
"receive_id": chatID,
},
})
require.NoError(t, err)
skipIfMissingIMForwardPermission(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
forwardedID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, forwardedID, "stdout:\n%s", result.Stdout)
require.NotEqual(t, messageID, forwardedID, "stdout:\n%s", result.Stdout)
require.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout)
})
var threadID string
t.Run("create thread fixture as bot", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-reply",
"--message-id", messageID,
"--text", replyText,
"--reply-in-thread",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
threadID = findThreadIDForMessage(t, ctx, chatID, messageID, "bot")
})
t.Run("forward thread with api command as user", func(t *testing.T) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "threads", "forward"},
DefaultAs: "user",
Params: map[string]any{
"thread_id": threadID,
"receive_id_type": "chat_id",
"uuid": "thread-forward-" + suffix,
},
Data: map[string]any{
"receive_id": chatID,
},
})
require.NoError(t, err)
skipIfMissingIMForwardPermission(t, result)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, 0)
forwardedID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, forwardedID, "stdout:\n%s", result.Stdout)
require.Equal(t, chatID, gjson.Get(result.Stdout, "data.chat_id").String(), "stdout:\n%s", result.Stdout)
require.Equal(t, "merge_forward", gjson.Get(result.Stdout, "data.msg_type").String(), "stdout:\n%s", result.Stdout)
})
}
func findThreadIDForMessage(t *testing.T, ctx context.Context, chatID string, messageID string, defaultAs string) string {
t.Helper()
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{
"im", "+chat-messages-list",
"--chat-id", chatID,
"--start", time.Now().UTC().Add(-10 * time.Minute).Format(time.RFC3339),
"--end", time.Now().UTC().Add(10 * time.Minute).Format(time.RFC3339),
},
DefaultAs: defaultAs,
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
for _, item := range gjson.Get(result.Stdout, "data.messages").Array() {
if item.Get("message_id").String() == messageID && item.Get("thread_id").String() != "" {
return false
}
}
return true
},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
for _, item := range gjson.Get(listResult.Stdout, "data.messages").Array() {
if item.Get("message_id").String() == messageID {
threadID := item.Get("thread_id").String()
require.NotEmpty(t, threadID, "expected thread_id for message %s in stdout:\n%s", messageID, listResult.Stdout)
return threadID
}
}
t.Fatalf("expected message %s in stdout:\n%s", messageID, listResult.Stdout)
return ""
}
func getSelfOpenID(t *testing.T, ctx context.Context) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"contact", "+get-user"},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
openID := gjson.Get(result.Stdout, "data.user.open_id").String()
require.NotEmpty(t, openID, "stdout:\n%s", result.Stdout)
return openID
}
func sendDirectMessageToUser(t *testing.T, ctx context.Context, userOpenID string, text string, defaultAs string) (string, string) {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"im", "+messages-send",
"--user-id", userOpenID,
"--text", text,
},
DefaultAs: defaultAs,
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
chatID := gjson.Get(result.Stdout, "data.chat_id").String()
messageID := gjson.Get(result.Stdout, "data.message_id").String()
require.NotEmpty(t, chatID, "stdout:\n%s", result.Stdout)
require.NotEmpty(t, messageID, "stdout:\n%s", result.Stdout)
return chatID, messageID
}
func skipIfMissingIMForwardPermission(t *testing.T, result *clie2e.Result) {
t.Helper()
if result == nil || result.ExitCode == 0 {
return
}
stderrLower := strings.ToLower(result.Stderr)
if strings.Contains(stderrLower, "permission denied") ||
strings.Contains(stderrLower, "230027") ||
strings.Contains(stderrLower, "missing_scope") {
t.Skipf("skip UAT forward workflow due to missing IM forward permissions: %s", result.Stderr)
}
}