mirror of
https://github.com/larksuite/cli.git
synced 2026-07-04 06:29:52 +08:00
Compare commits
6 Commits
chore/spli
...
pr-870
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
336eeaf18e | ||
|
|
37459b60ec | ||
|
|
f1aa7d8f42 | ||
|
|
a18504b1f9 | ||
|
|
5e0ac02f08 | ||
|
|
b0c9a4d74e |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,6 +2,22 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [v1.0.30] - 2026-05-13
|
||||
|
||||
### Features
|
||||
|
||||
- **im**: Add `--chat-mode topic` to `+chat-create` (#790)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **auth**: Support comma-separated `--scope` in `auth login` (#764)
|
||||
- **auth**: Clarify URL handling in auth messages and docs (#856)
|
||||
- **bind**: Accept `~/` paths in OpenClaw secret references (#839)
|
||||
|
||||
### Tests
|
||||
|
||||
- **update**: Isolate stamp writes from real `~/.lark-cli/skills.stamp` (#858)
|
||||
|
||||
## [v1.0.29] - 2026-05-12
|
||||
|
||||
### Features
|
||||
@@ -676,6 +692,7 @@ Bundled AI agent skills for intelligent assistance:
|
||||
- Bilingual documentation (English & Chinese).
|
||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||
|
||||
[v1.0.30]: https://github.com/larksuite/cli/releases/tag/v1.0.30
|
||||
[v1.0.29]: https://github.com/larksuite/cli/releases/tag/v1.0.29
|
||||
[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
|
||||
|
||||
@@ -30,6 +30,7 @@ type LoginOptions struct {
|
||||
Scope string
|
||||
Recommend bool
|
||||
Domains []string
|
||||
Exclude []string
|
||||
NoWait bool
|
||||
DeviceCode string
|
||||
}
|
||||
@@ -62,11 +63,13 @@ browser. Run it in the background and retrieve the verification URL from its out
|
||||
}
|
||||
cmdutil.SetSupportedIdentities(cmd, []string{"user"})
|
||||
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space-separated)")
|
||||
cmd.Flags().StringVar(&opts.Scope, "scope", "", "scopes to request (space- or comma-separated). Combines additively with --domain/--recommend")
|
||||
cmd.Flags().BoolVar(&opts.Recommend, "recommend", false, "request only recommended (auto-approve) scopes")
|
||||
available := sortedKnownDomains()
|
||||
cmd.Flags().StringSliceVar(&opts.Domains, "domain", nil,
|
||||
fmt.Sprintf("domain (repeatable or comma-separated, e.g. --domain calendar,task)\navailable: %s, all", strings.Join(available, ", ")))
|
||||
cmd.Flags().StringSliceVar(&opts.Exclude, "exclude", nil,
|
||||
"scopes to exclude from the request (repeatable or comma-separated, e.g. --exclude drive:file:download)")
|
||||
cmd.Flags().BoolVar(&opts.JSON, "json", false, "structured JSON output")
|
||||
cmd.Flags().BoolVar(&opts.NoWait, "no-wait", false, "initiate device authorization and return immediately; use --device-code to complete")
|
||||
cmd.Flags().StringVar(&opts.DeviceCode, "device-code", "", "poll and complete authorization with a device code from a previous --no-wait call")
|
||||
@@ -158,6 +161,10 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
|
||||
hasAnyOption := opts.Scope != "" || opts.Recommend || len(selectedDomains) > 0
|
||||
|
||||
if len(opts.Exclude) > 0 && !hasAnyOption {
|
||||
return output.ErrValidation("--exclude requires --scope, --domain, or --recommend to be specified")
|
||||
}
|
||||
|
||||
if !hasAnyOption {
|
||||
if !opts.JSON && f.IOStreams.IsTerminal {
|
||||
result, err := runInteractiveLogin(f.IOStreams, lang, msg)
|
||||
@@ -185,14 +192,17 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
}
|
||||
}
|
||||
|
||||
finalScope := opts.Scope
|
||||
// Normalize --scope so users can pass either OAuth-standard space-separated
|
||||
// values or the more natural comma-separated list. RFC 6749 §3.3 mandates
|
||||
// space-delimited scopes in the wire request, so the device authorization
|
||||
// endpoint rejects raw "a,b" strings as a single malformed scope.
|
||||
finalScope := normalizeScopeInput(opts.Scope)
|
||||
|
||||
// Resolve scopes from domain/permission filters
|
||||
// Resolve scopes from domain/permission filters and merge with --scope.
|
||||
// --scope, --domain, and --recommend combine additively so callers can,
|
||||
// for example, request all `docs` scopes plus a few specific `drive`
|
||||
// scopes in a single command.
|
||||
if len(selectedDomains) > 0 || opts.Recommend {
|
||||
if opts.Scope != "" {
|
||||
return output.ErrValidation("cannot use --scope together with --domain/--recommend")
|
||||
}
|
||||
|
||||
var candidateScopes []string
|
||||
if len(selectedDomains) > 0 {
|
||||
candidateScopes = collectScopesForDomains(selectedDomains, "user")
|
||||
@@ -206,11 +216,35 @@ func authLoginRun(opts *LoginOptions) error {
|
||||
candidateScopes = registry.FilterAutoApproveScopes(candidateScopes)
|
||||
}
|
||||
|
||||
if len(candidateScopes) == 0 {
|
||||
if len(candidateScopes) == 0 && opts.Scope == "" {
|
||||
return output.ErrValidation("no matching scopes found, check domain/scope options")
|
||||
}
|
||||
|
||||
finalScope = strings.Join(candidateScopes, " ")
|
||||
// Merge --scope additively with the resolved domain scopes.
|
||||
merged := make(map[string]bool, len(candidateScopes)+len(strings.Fields(finalScope)))
|
||||
for _, s := range candidateScopes {
|
||||
merged[s] = true
|
||||
}
|
||||
for _, s := range strings.Fields(finalScope) {
|
||||
merged[s] = true
|
||||
}
|
||||
finalScope = joinSortedScopeSet(merged)
|
||||
}
|
||||
|
||||
// Apply --exclude on top of the resolved scope set. We honour exclude
|
||||
// regardless of whether scopes came from --scope, --domain, --recommend,
|
||||
// or any combination thereof.
|
||||
if len(opts.Exclude) > 0 {
|
||||
excluded, unknown := applyExcludeScopes(finalScope, opts.Exclude)
|
||||
if len(unknown) > 0 {
|
||||
return output.ErrValidation(
|
||||
"these --exclude scopes are not present in the requested set: %s",
|
||||
strings.Join(unknown, ", "))
|
||||
}
|
||||
finalScope = excluded
|
||||
if strings.TrimSpace(finalScope) == "" {
|
||||
return output.ErrValidation("no scopes left after applying --exclude; nothing to authorize")
|
||||
}
|
||||
}
|
||||
|
||||
// Step 1: Request device authorization
|
||||
@@ -532,6 +566,40 @@ func shortcutSupportsIdentity(sc common.Shortcut, identity string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// normalizeScopeInput accepts a user-supplied --scope value that may use
|
||||
// commas, spaces, tabs, or newlines (or any mix) as separators and returns the
|
||||
// canonical OAuth 2.0 wire form: a single space-joined string with empties
|
||||
// trimmed and duplicates removed (first occurrence wins; order preserved).
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// "vc:note:read,vc:meeting.meetingevent:read" -> "vc:note:read vc:meeting.meetingevent:read"
|
||||
// "a, b , c" -> "a b c"
|
||||
// "a b a" -> "a b"
|
||||
// "" -> ""
|
||||
func normalizeScopeInput(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
// Treat both commas and any whitespace as separators.
|
||||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||||
return r == ',' || r == ' ' || r == '\t' || r == '\n' || r == '\r'
|
||||
})
|
||||
if len(fields) == 0 {
|
||||
return ""
|
||||
}
|
||||
seen := make(map[string]struct{}, len(fields))
|
||||
out := make([]string, 0, len(fields))
|
||||
for _, f := range fields {
|
||||
if _, ok := seen[f]; ok {
|
||||
continue
|
||||
}
|
||||
seen[f] = struct{}{}
|
||||
out = append(out, f)
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// suggestDomain finds the best "did you mean" match for an unknown domain.
|
||||
func suggestDomain(input string, known map[string]bool) string {
|
||||
// Check common cases: prefix match or input is a substring
|
||||
@@ -542,3 +610,58 @@ func suggestDomain(input string, known map[string]bool) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// joinSortedScopeSet returns a deterministic, space-separated scope string
|
||||
// from a set, sorted alphabetically. Empty/blank scopes are dropped.
|
||||
func joinSortedScopeSet(set map[string]bool) string {
|
||||
out := make([]string, 0, len(set))
|
||||
for s := range set {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// applyExcludeScopes removes the provided exclude entries from the requested
|
||||
// scope string. Each --exclude flag value may itself contain comma- or
|
||||
// whitespace-separated scopes. Returns the filtered scope string and any
|
||||
// exclude entries that were not present in the requested set (callers can
|
||||
// surface those as a validation error to catch typos like
|
||||
// `--exclude drive:file:downlod`).
|
||||
func applyExcludeScopes(requested string, excludes []string) (string, []string) {
|
||||
requestedSet := make(map[string]bool)
|
||||
for _, s := range strings.Fields(requested) {
|
||||
requestedSet[s] = true
|
||||
}
|
||||
|
||||
excludeSet := make(map[string]bool)
|
||||
for _, raw := range excludes {
|
||||
// --exclude already splits on commas (StringSliceVar), but also
|
||||
// tolerate whitespace-separated entries inside a single value.
|
||||
for _, s := range strings.Fields(strings.ReplaceAll(raw, ",", " ")) {
|
||||
excludeSet[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
var unknown []string
|
||||
for s := range excludeSet {
|
||||
if !requestedSet[s] {
|
||||
unknown = append(unknown, s)
|
||||
}
|
||||
}
|
||||
if len(unknown) > 0 {
|
||||
sort.Strings(unknown)
|
||||
return requested, unknown
|
||||
}
|
||||
|
||||
kept := make(map[string]bool, len(requestedSet))
|
||||
for s := range requestedSet {
|
||||
if !excludeSet[s] {
|
||||
kept[s] = true
|
||||
}
|
||||
}
|
||||
return joinSortedScopeSet(kept), nil
|
||||
}
|
||||
|
||||
@@ -70,6 +70,32 @@ func TestSuggestDomain_ExactMatch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeScopeInput(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"single", "vc:note:read", "vc:note:read"},
|
||||
{"comma", "vc:note:read,vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"space", "vc:note:read vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"comma_and_spaces", "vc:note:read, vc:meeting.meetingevent:read", "vc:note:read vc:meeting.meetingevent:read"},
|
||||
{"mixed_separators", "a, b\tc\nd e", "a b c d e"},
|
||||
{"trim_and_dedup", " a , b , a ", "a b"},
|
||||
{"trailing_separators", "a,b,,", "a b"},
|
||||
{"only_separators", " , , ", ""},
|
||||
{"tab_separated", "im:message:send\toffline_access", "im:message:send offline_access"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := normalizeScopeInput(tc.in); got != tc.want {
|
||||
t.Errorf("normalizeScopeInput(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortcutSupportsIdentity_DefaultUser(t *testing.T) {
|
||||
// Empty AuthTypes defaults to ["user"]
|
||||
sc := common.Shortcut{AuthTypes: nil}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@larksuite/cli",
|
||||
"version": "1.0.29",
|
||||
"version": "1.0.30",
|
||||
"description": "The official CLI for Lark/Feishu open platform",
|
||||
"bin": {
|
||||
"lark-cli": "scripts/run.js"
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -625,6 +626,94 @@ func TestChooseRemoteFileSortsByParsedTimes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestChooseRemoteFileSortsMixedUnitEpochsByActualTime verifies duplicate
|
||||
// resolution compares actual timestamps rather than raw integer magnitudes when
|
||||
// Drive mixes second- and millisecond-resolution epoch strings.
|
||||
func TestChooseRemoteFileSortsMixedUnitEpochsByActualTime(t *testing.T) {
|
||||
files := []driveRemoteEntry{
|
||||
{FileToken: "token_seconds", CreatedTime: "1715594881", ModifiedTime: "1715594881"},
|
||||
{FileToken: "token_millis", CreatedTime: "1715594880123", ModifiedTime: "1715594880123"},
|
||||
}
|
||||
gotNewest, err := chooseRemoteFile(files, driveDuplicateRemoteNewest)
|
||||
if err != nil {
|
||||
t.Fatalf("chooseRemoteFile newest: %v", err)
|
||||
}
|
||||
if gotNewest.FileToken != "token_seconds" {
|
||||
t.Fatalf("newest token = %q, want token_seconds", gotNewest.FileToken)
|
||||
}
|
||||
gotOldest, err := chooseRemoteFile(files, driveDuplicateRemoteOldest)
|
||||
if err != nil {
|
||||
t.Fatalf("chooseRemoteFile oldest: %v", err)
|
||||
}
|
||||
if gotOldest.FileToken != "token_millis" {
|
||||
t.Fatalf("oldest token = %q, want token_millis", gotOldest.FileToken)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePushDeleteRemoteKeepsActualNewestDuplicateAcrossMixedEpochUnits
|
||||
// proves the duplicate selector and delete pass agree on the true newest file
|
||||
// even when remote timestamps use mixed epoch units.
|
||||
func TestDrivePushDeleteRemoteKeepsActualNewestDuplicateAcrossMixedEpochUnits(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("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
|
||||
registerRemoteListing(reg, "folder_root", []map[string]interface{}{
|
||||
{"token": duplicateRemoteFileIDFirst, "name": "dup.txt", "type": "file", "size": 5, "created_time": "1715594880123", "modified_time": "1715594880123"},
|
||||
{"token": duplicateRemoteFileIDSecond, "name": "dup.txt", "type": "file", "size": 6, "created_time": "1715594881", "modified_time": "1715594881"},
|
||||
})
|
||||
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": "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(), "dup.txt", "deleted_remote", duplicateRemoteFileIDFirst)
|
||||
if deleteStub.CapturedHeaders == nil {
|
||||
t.Fatal("DELETE for older mixed-unit duplicate sibling was never issued")
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestChooseRemoteFileFallsBackToFileTokenOnTimeParseFailure(t *testing.T) {
|
||||
files := []driveRemoteEntry{
|
||||
{FileToken: "token_a", CreatedTime: "bad", ModifiedTime: "bad"},
|
||||
@@ -646,6 +735,46 @@ func TestChooseRemoteFileRejectsEmptyCandidates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareDriveRemoteModifiedToLocalSupportsSecondAndMillisecondEpochs(t *testing.T) {
|
||||
t.Run("second resolution truncates local mtime", func(t *testing.T) {
|
||||
cmp, ok := compareDriveRemoteModifiedToLocal("100", time.Unix(100, 900*int64(time.Millisecond)))
|
||||
if !ok {
|
||||
t.Fatal("expected second-resolution timestamp to parse")
|
||||
}
|
||||
if cmp != 0 {
|
||||
t.Fatalf("cmp = %d, want 0 when local only differs below second resolution", cmp)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("millisecond resolution stays precise", func(t *testing.T) {
|
||||
const remoteMillis = int64(1715594880123)
|
||||
cmp, ok := compareDriveRemoteModifiedToLocal(strconv.FormatInt(remoteMillis, 10), time.UnixMilli(remoteMillis))
|
||||
if !ok {
|
||||
t.Fatal("expected millisecond-resolution timestamp to parse")
|
||||
}
|
||||
if cmp != 0 {
|
||||
t.Fatalf("cmp = %d, want 0 for equal millisecond timestamps", cmp)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("microsecond resolution stays precise", func(t *testing.T) {
|
||||
const remoteMicros = int64(1715594880123456)
|
||||
cmp, ok := compareDriveRemoteModifiedToLocal(strconv.FormatInt(remoteMicros, 10), time.UnixMicro(remoteMicros))
|
||||
if !ok {
|
||||
t.Fatal("expected microsecond-resolution timestamp to parse")
|
||||
}
|
||||
if cmp != 0 {
|
||||
t.Fatalf("cmp = %d, want 0 for equal microsecond timestamps", cmp)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid timestamp is rejected", func(t *testing.T) {
|
||||
if _, ok := compareDriveRemoteModifiedToLocal("not-a-time", time.Now()); ok {
|
||||
t.Fatal("expected invalid remote timestamp to be rejected")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestDrivePullRemoteViewsRejectsUnknownStrategy(t *testing.T) {
|
||||
_, _, err := drivePullRemoteViews([]driveRemoteEntry{
|
||||
{RelPath: "dup.txt", Type: driveTypeFile, FileToken: duplicateRemoteFileIDFirst},
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
@@ -20,8 +21,18 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
var drivePullChtimes = drivePullApplyChtimes
|
||||
|
||||
// drivePullApplyChtimes is a tiny indirection that keeps the production path on
|
||||
// os.Chtimes while still letting tests inject mtime failures without requiring a
|
||||
// custom filesystem implementation.
|
||||
func drivePullApplyChtimes(path string, atime, mtime time.Time) error {
|
||||
return os.Chtimes(path, atime, mtime) //nolint:forbidigo // FileIO exposes no mtime mutation API yet; callers resolve and bound the path first.
|
||||
}
|
||||
|
||||
const (
|
||||
drivePullIfExistsOverwrite = "overwrite"
|
||||
drivePullIfExistsSmart = "smart"
|
||||
drivePullIfExistsSkip = "skip"
|
||||
)
|
||||
|
||||
@@ -37,6 +48,7 @@ type drivePullTarget struct {
|
||||
DownloadToken string
|
||||
ItemFileToken string
|
||||
ItemSourceID string
|
||||
ModifiedTime string
|
||||
}
|
||||
|
||||
// DrivePull performs a one-way file-level mirror from a Drive folder onto
|
||||
@@ -60,7 +72,7 @@ var DrivePull = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "source Drive folder token", Required: true},
|
||||
{Name: "if-exists", Desc: "policy when a local file already exists", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSkip}},
|
||||
{Name: "if-exists", Desc: "policy when a local file already exists (skip = never touch existing files; smart = skip when local mtime is already up to date; overwrite = always replace)", Default: drivePullIfExistsOverwrite, Enum: []string{drivePullIfExistsOverwrite, drivePullIfExistsSmart, 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"},
|
||||
@@ -68,6 +80,7 @@ var DrivePull = common.Shortcut{
|
||||
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.",
|
||||
"For repeat syncs, --if-exists=smart is the recommended best-effort incremental mode: it compares local mtime with Drive modified_time and skips downloads when the local copy is already up to date.",
|
||||
"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.",
|
||||
},
|
||||
@@ -202,14 +215,14 @@ var DrivePull = common.Shortcut{
|
||||
downloadFailed++
|
||||
continue
|
||||
}
|
||||
if ifExists == drivePullIfExistsSkip {
|
||||
if ifExists == drivePullIfExistsSkip || drivePullShouldSkipSmart(target, targetFile, ifExists, runtime) {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "skipped"})
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if err := drivePullDownload(ctx, runtime, downloadToken, target); err != nil {
|
||||
if err := drivePullDownload(ctx, runtime, downloadToken, target, targetFile.ModifiedTime); err != nil {
|
||||
items = append(items, drivePullItem{RelPath: rel, FileToken: itemFileToken, SourceID: itemSourceID, Action: "failed", Error: err.Error()})
|
||||
failed++
|
||||
downloadFailed++
|
||||
@@ -305,7 +318,9 @@ var DrivePull = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target string) error {
|
||||
// drivePullDownload streams one Drive file into the local mirror target and
|
||||
// then best-effort aligns the local mtime to Drive's modified_time.
|
||||
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {
|
||||
resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
|
||||
HttpMethod: "GET",
|
||||
ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)),
|
||||
@@ -320,9 +335,53 @@ func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, file
|
||||
}, resp.Body); err != nil {
|
||||
return common.WrapSaveErrorByCategory(err, "io")
|
||||
}
|
||||
if err := drivePullApplyRemoteModifiedTime(target, remoteModifiedTime, runtime); err != nil {
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Downloaded %s but could not preserve remote modified_time: %s\n", target, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// drivePullApplyRemoteModifiedTime preserves Drive's modified_time on a local
|
||||
// file when the remote timestamp is parseable and the target path is safe.
|
||||
func drivePullApplyRemoteModifiedTime(target, remoteModifiedTime string, runtime *common.RuntimeContext) error {
|
||||
remoteTime, _, ok := parseDriveEpoch(remoteModifiedTime)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
resolved, err := runtime.FileIO().ResolvePath(target)
|
||||
if err != nil {
|
||||
return output.ErrValidation("unsafe output path: %s", err)
|
||||
}
|
||||
if err := drivePullChtimes(resolved, remoteTime, remoteTime); err != nil {
|
||||
return output.Errorf(output.ExitInternal, "io", "cannot preserve remote modified_time on local file: %s", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func drivePullShouldSkipSmart(target string, remoteFile drivePullTarget, ifExists string, runtime *common.RuntimeContext) bool {
|
||||
if ifExists != drivePullIfExistsSmart {
|
||||
return false
|
||||
}
|
||||
if remoteFile.ModifiedTime == "" {
|
||||
return false
|
||||
}
|
||||
resolved, err := runtime.FileIO().ResolvePath(target)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
info, err := os.Stat(resolved) //nolint:forbidigo // FileIO exposes no ModTime-capable Stat; ResolvePath already bounded the path.
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
cmp, ok := compareDriveRemoteModifiedToLocal(remoteFile.ModifiedTime, info.ModTime())
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Local is already at least as new as the remote file, so another
|
||||
// download would be redundant.
|
||||
return cmp <= 0
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -346,7 +405,7 @@ func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m
|
||||
for _, rel := range relPaths {
|
||||
files := fileGroups[rel]
|
||||
if len(files) == 1 {
|
||||
remoteFiles[rel] = drivePullTarget{DownloadToken: files[0].FileToken, ItemFileToken: files[0].FileToken}
|
||||
remoteFiles[rel] = drivePullTarget{DownloadToken: files[0].FileToken, ItemFileToken: files[0].FileToken, ModifiedTime: files[0].ModifiedTime}
|
||||
remotePaths[rel] = struct{}{}
|
||||
continue
|
||||
}
|
||||
@@ -366,6 +425,7 @@ func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m
|
||||
remoteFiles[targetRel] = drivePullTarget{
|
||||
DownloadToken: file.FileToken,
|
||||
ItemSourceID: stableTokenIdentifier(file.FileToken),
|
||||
ModifiedTime: file.ModifiedTime,
|
||||
}
|
||||
remotePaths[targetRel] = struct{}{}
|
||||
}
|
||||
@@ -374,7 +434,7 @@ func drivePullRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (m
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken}
|
||||
remoteFiles[rel] = drivePullTarget{DownloadToken: chosen.FileToken, ItemFileToken: chosen.FileToken, ModifiedTime: chosen.ModifiedTime}
|
||||
remotePaths[rel] = struct{}{}
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported duplicate remote strategy %q", duplicateRemote)
|
||||
|
||||
@@ -4,17 +4,23 @@
|
||||
package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/core"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestDrivePullDownloadsAndCreatesParents verifies the happy path: a remote
|
||||
@@ -151,6 +157,322 @@ func TestDrivePullSkipsExistingWhenSkipPolicy(t *testing.T) {
|
||||
mustReadFile(t, filepath.Join("local", "keep.txt"), "local-original")
|
||||
}
|
||||
|
||||
// TestDrivePullSkipsExistingWhenSmartPolicyAndLocalIsUpToDate verifies the
|
||||
// smart fast path for Drive → local mirrors: when the local copy is already
|
||||
// at least as new as the remote file, +pull skips the download.
|
||||
func TestDrivePullSkipsExistingWhenSmartPolicyAndLocalIsUpToDate(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)
|
||||
}
|
||||
localPath := filepath.Join("local", "keep.txt")
|
||||
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
localMTime := time.Unix(200, 500*int64(time.Millisecond))
|
||||
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
||||
t.Fatalf("Chtimes: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "100"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Intentionally NO download stub: smart mode should skip the transfer.
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "smart",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"skipped": 1`) {
|
||||
t.Errorf("expected skipped=1, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"downloaded": 0`) {
|
||||
t.Errorf("expected downloaded=0, got: %s", out)
|
||||
}
|
||||
mustReadFile(t, localPath, "hello")
|
||||
}
|
||||
|
||||
// TestDrivePullDownloadsWhenSmartPolicyAndRemoteIsNewer verifies the smart
|
||||
// policy still downloads when the remote file is newer than the local copy.
|
||||
func TestDrivePullDownloadsWhenSmartPolicyAndRemoteIsNewer(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)
|
||||
}
|
||||
localPath := filepath.Join("local", "keep.txt")
|
||||
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
localMTime := time.Unix(100, 500*int64(time.Millisecond))
|
||||
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
||||
t.Fatalf("Chtimes: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "200"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_keep/download",
|
||||
Status: 200,
|
||||
Body: []byte("WORLD"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "smart",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"downloaded": 1`) {
|
||||
t.Errorf("expected downloaded=1, got: %s", out)
|
||||
}
|
||||
mustReadFile(t, localPath, "WORLD")
|
||||
info, err := os.Stat(localPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Stat: %v", err)
|
||||
}
|
||||
if got, want := info.ModTime(), time.Unix(200, 0); !got.Equal(want) {
|
||||
t.Fatalf("local mtime = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePullTreatsModifiedTimePreservationFailureAsNotice verifies a local
|
||||
// write that succeeds but cannot preserve remote modified_time still reports a
|
||||
// successful download and only emits an operator-facing notice on stderr.
|
||||
func TestDrivePullTreatsModifiedTimePreservationFailureAsNotice(t *testing.T) {
|
||||
f, stdout, stderrBuf, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
|
||||
prevChtimes := drivePullChtimes
|
||||
drivePullChtimes = func(string, time.Time, time.Time) error {
|
||||
return fmt.Errorf("mtime mutation unsupported")
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
drivePullChtimes = prevChtimes
|
||||
})
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "200"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "/open-apis/drive/v1/files/tok_keep/download",
|
||||
Status: 200,
|
||||
Body: []byte("WORLD"),
|
||||
Headers: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--delete-local",
|
||||
"--yes",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", err, stdout.String(), stderrBuf.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"downloaded": 1`) {
|
||||
t.Errorf("expected downloaded=1, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"failed": 0`) {
|
||||
t.Errorf("expected failed=0, got: %s", out)
|
||||
}
|
||||
mustReadFile(t, filepath.Join("local", "keep.txt"), "WORLD")
|
||||
if !strings.Contains(stderrBuf.String(), "could not preserve remote modified_time") {
|
||||
t.Errorf("expected stderr notice about modified_time preservation failure, got: %s", stderrBuf.String())
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDrivePullShouldSkipSmartFallsBackWhenMetadataCannotBeTrusted(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
localPath := filepath.Join("local", "keep.txt")
|
||||
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
localMTime := time.Unix(100, 500*int64(time.Millisecond))
|
||||
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
||||
t.Fatalf("Chtimes: %v", err)
|
||||
}
|
||||
|
||||
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot)
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
ifExists string
|
||||
remoteFile drivePullTarget
|
||||
}{
|
||||
{
|
||||
name: "non-smart policy",
|
||||
ifExists: drivePullIfExistsOverwrite,
|
||||
remoteFile: drivePullTarget{ModifiedTime: "100"},
|
||||
},
|
||||
{
|
||||
name: "missing remote timestamp",
|
||||
ifExists: drivePullIfExistsSmart,
|
||||
remoteFile: drivePullTarget{ModifiedTime: ""},
|
||||
},
|
||||
{
|
||||
name: "invalid remote timestamp",
|
||||
ifExists: drivePullIfExistsSmart,
|
||||
remoteFile: drivePullTarget{ModifiedTime: "not-a-time"},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := drivePullShouldSkipSmart(localPath, tt.remoteFile, tt.ifExists, runtime); got {
|
||||
t.Fatalf("drivePullShouldSkipSmart() = true, want false for %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePullShouldSkipSmartFallsBackWhenPathCannotBeResolved(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot)
|
||||
|
||||
if got := drivePullShouldSkipSmart("../escape.txt", drivePullTarget{ModifiedTime: "100"}, drivePullIfExistsSmart, runtime); got {
|
||||
t.Fatal("drivePullShouldSkipSmart() = true, want false when ResolvePath rejects the target")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePullShouldSkipSmartFallsBackWhenLocalFileDisappeared(t *testing.T) {
|
||||
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
if err := os.MkdirAll("local", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
runtime := common.TestNewRuntimeContextForAPI(context.Background(), &cobra.Command{Use: "test"}, driveTestConfig(), f, core.AsBot)
|
||||
|
||||
if got := drivePullShouldSkipSmart(filepath.Join("local", "missing.txt"), drivePullTarget{ModifiedTime: "100"}, drivePullIfExistsSmart, runtime); got {
|
||||
t.Fatal("drivePullShouldSkipSmart() = true, want false when os.Stat cannot find the local file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePullSkipsWhenSmartIgnoresRemoteSize(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)
|
||||
}
|
||||
localPath := filepath.Join("local", "keep.txt")
|
||||
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
localMTime := time.Unix(200, 500*int64(time.Millisecond))
|
||||
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
||||
t.Fatalf("Chtimes: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 999, "modified_time": "100"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePull, []string{
|
||||
"+pull",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "smart",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"skipped": 1`) {
|
||||
t.Errorf("expected skipped=1, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"downloaded": 0`) {
|
||||
t.Errorf("expected downloaded=0, got: %s", out)
|
||||
}
|
||||
mustReadFile(t, localPath, "hello")
|
||||
}
|
||||
|
||||
// TestDrivePullSurfacesDirectoryFileMirrorConflict pins the contract
|
||||
// for the case where Drive ships a regular file at a rel_path that is
|
||||
// already a directory locally. SafeOutputPath would refuse to overwrite
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
@@ -25,6 +26,7 @@ import (
|
||||
|
||||
const (
|
||||
drivePushIfExistsOverwrite = "overwrite"
|
||||
drivePushIfExistsSmart = "smart"
|
||||
drivePushIfExistsSkip = "skip"
|
||||
)
|
||||
|
||||
@@ -91,7 +93,7 @@ var DrivePush = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{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: "if-exists", Desc: "policy when a Drive file already exists at the same rel_path (skip = never touch existing remote files; smart = skip when remote modified_time already matches or is newer, otherwise fall through to overwrite semantics; overwrite = always replace)", Default: drivePushIfExistsSkip, Enum: []string{drivePushIfExistsOverwrite, drivePushIfExistsSmart, 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"},
|
||||
@@ -99,8 +101,9 @@ var DrivePush = common.Shortcut{
|
||||
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.",
|
||||
"For repeat syncs, --if-exists=smart is a best-effort incremental mode: it compares local mtime with Drive modified_time and skips uploads when the remote copy is already up to date; otherwise it falls through to the same overwrite path as --if-exists=overwrite.",
|
||||
"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.",
|
||||
"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. The same caveat applies when --if-exists=smart decides the remote file is older and falls through to overwrite.",
|
||||
"--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.",
|
||||
"Item-level failures (upload, overwrite, folder, delete) bump summary.failed and the run exits non-zero. If any upload or folder step fails, the --delete-remote phase is skipped entirely so a partial upload never triggers remote deletion.",
|
||||
@@ -151,7 +154,7 @@ var DrivePush = common.Shortcut{
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Walk --local-dir, recursively list --folder-token, then upload new files, overwrite (when --if-exists=overwrite) or skip existing, and (when --delete-remote --yes is set) delete Drive files absent locally.").
|
||||
Desc("Walk --local-dir, recursively list --folder-token, then upload new files, skip existing, skip up-to-date files when --if-exists=smart, overwrite when --if-exists=overwrite, and (when --delete-remote --yes is set) delete Drive files absent locally.").
|
||||
GET("/open-apis/drive/v1/files").
|
||||
Set("folder_token", runtime.Str("folder-token"))
|
||||
},
|
||||
@@ -267,7 +270,7 @@ var DrivePush = common.Shortcut{
|
||||
localFile := localFiles[rel]
|
||||
|
||||
if entry, ok := remoteFiles[rel]; ok {
|
||||
if ifExists == drivePushIfExistsSkip {
|
||||
if drivePushShouldSkipExisting(localFile, entry, ifExists) {
|
||||
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "skipped", SizeBytes: localFile.Size})
|
||||
skipped++
|
||||
continue
|
||||
@@ -394,6 +397,7 @@ type drivePushLocalFile struct {
|
||||
OpenPath string
|
||||
FileName string
|
||||
Size int64
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
// drivePushWalkLocal walks the canonical absolute root produced by
|
||||
@@ -450,6 +454,7 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil
|
||||
OpenPath: relToCwd,
|
||||
FileName: filepath.Base(rel),
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -473,6 +478,30 @@ func drivePushWalkLocal(root, cwdCanonical string) (map[string]drivePushLocalFil
|
||||
return files, dirs, nil
|
||||
}
|
||||
|
||||
func drivePushShouldSkipExisting(localFile drivePushLocalFile, remoteFile driveRemoteEntry, ifExists string) bool {
|
||||
switch ifExists {
|
||||
case drivePushIfExistsSkip:
|
||||
return true
|
||||
case drivePushIfExistsSmart:
|
||||
return drivePushShouldSkipSmart(localFile, remoteFile)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func drivePushShouldSkipSmart(localFile drivePushLocalFile, remoteFile driveRemoteEntry) bool {
|
||||
cmp, ok := compareDriveRemoteModifiedToLocal(remoteFile.ModifiedTime, localFile.ModTime)
|
||||
if !ok {
|
||||
// Smart mode is an optimization. If the timestamp is missing or
|
||||
// malformed, fall back to the safe transfer path instead of silently
|
||||
// skipping an update we could not compare.
|
||||
return false
|
||||
}
|
||||
// Remote is already at least as new as the local file, so another
|
||||
// upload would be redundant.
|
||||
return cmp >= 0
|
||||
}
|
||||
|
||||
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))
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/extension/fileio"
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
@@ -324,6 +325,203 @@ func TestDrivePushSkipsWhenIfExistsSkip(t *testing.T) {
|
||||
// would 404 against the registry and the run would have errored above.
|
||||
}
|
||||
|
||||
// TestDrivePushSkipsWhenIfExistsSmartAndRemoteIsUpToDate verifies the smart
|
||||
// fast path for local → Drive mirrors: when the remote copy is already at
|
||||
// least as new as the local file, +push skips the upload.
|
||||
func TestDrivePushSkipsWhenIfExistsSmartAndRemoteIsUpToDate(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)
|
||||
}
|
||||
localPath := filepath.Join("local", "keep.txt")
|
||||
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
localMTime := time.Unix(100, 500*int64(time.Millisecond))
|
||||
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
||||
t.Fatalf("Chtimes: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "200"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Intentionally NO upload_all stub: smart mode should skip the transfer.
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "smart",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"skipped": 1`) {
|
||||
t.Errorf("expected skipped=1, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"uploaded": 0`) {
|
||||
t.Errorf("expected uploaded=0, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePushOverwritesWhenIfExistsSmartAndLocalIsNewer verifies the smart
|
||||
// path still uploads when the local file is newer than the remote one.
|
||||
func TestDrivePushOverwritesWhenIfExistsSmartAndLocalIsNewer(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)
|
||||
}
|
||||
localPath := filepath.Join("local", "keep.txt")
|
||||
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
localMTime := time.Unix(200, 500*int64(time.Millisecond))
|
||||
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
||||
t.Fatalf("Chtimes: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep_old", "name": "keep.txt", "type": "file", "size": 5, "modified_time": "100"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
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": "tok_keep_new", "version": "v43"},
|
||||
},
|
||||
}
|
||||
reg.Register(uploadStub)
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "smart",
|
||||
"--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": 1`) {
|
||||
t.Errorf("expected uploaded=1, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"action": "overwritten"`) {
|
||||
t.Errorf("expected overwritten action, got: %s", out)
|
||||
}
|
||||
body := decodeDriveMultipartBody(t, uploadStub)
|
||||
if got := body.Fields["file_token"]; got != "tok_keep_old" {
|
||||
t.Fatalf("upload_all form file_token = %q, want tok_keep_old", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushShouldSkipSmartFallsBackWhenMetadataCannotBeTrusted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
localFile := drivePushLocalFile{
|
||||
Size: 5,
|
||||
ModTime: time.Unix(100, 500*int64(time.Millisecond)),
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
remoteFile driveRemoteEntry
|
||||
}{
|
||||
{
|
||||
name: "invalid remote timestamp",
|
||||
remoteFile: driveRemoteEntry{ModifiedTime: "not-a-time"},
|
||||
},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := drivePushShouldSkipSmart(localFile, tt.remoteFile); got {
|
||||
t.Fatalf("drivePushShouldSkipSmart() = true, want false for %s", tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrivePushSkipsWhenSmartIgnoresRemoteSize(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)
|
||||
}
|
||||
localPath := filepath.Join("local", "keep.txt")
|
||||
if err := os.WriteFile(localPath, []byte("hello"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile: %v", err)
|
||||
}
|
||||
localMTime := time.Unix(100, 500*int64(time.Millisecond))
|
||||
if err := os.Chtimes(localPath, localMTime, localMTime); err != nil {
|
||||
t.Fatalf("Chtimes: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_keep", "name": "keep.txt", "type": "file", "size": 999, "modified_time": "200"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DrivePush, []string{
|
||||
"+push",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--if-exists", "smart",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"skipped": 1`) {
|
||||
t.Errorf("expected skipped=1, got: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"uploaded": 0`) {
|
||||
t.Errorf("expected uploaded=0, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrivePushDeleteRemoteRequiresYes locks in the upfront safety guard:
|
||||
// --delete-remote without --yes must be refused before any list / upload
|
||||
// happens, so a stray flag never silently deletes anything.
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
|
||||
@@ -26,8 +27,24 @@ type driveStatusEntry struct {
|
||||
FileToken string `json:"file_token,omitempty"`
|
||||
}
|
||||
|
||||
type driveStatusLocalFile struct {
|
||||
PathToCwd string
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
type driveStatusRemoteFile struct {
|
||||
FileToken string
|
||||
ModifiedTime string
|
||||
}
|
||||
|
||||
const (
|
||||
driveStatusDetectionExact = "exact"
|
||||
driveStatusDetectionQuick = "quick"
|
||||
)
|
||||
|
||||
// DriveStatus walks --local-dir, recursively lists --folder-token, and reports
|
||||
// four buckets (new_local, new_remote, modified, unchanged) by SHA-256 hash.
|
||||
// four buckets (new_local, new_remote, modified, unchanged) either by exact
|
||||
// SHA-256 hash (default) or by a quick modified_time comparison (--quick).
|
||||
//
|
||||
// Only Drive entries with type=file are compared; online docs (docx, sheet,
|
||||
// bitable, mindnote, slides) and shortcuts are skipped because there is no
|
||||
@@ -39,17 +56,19 @@ type driveStatusEntry struct {
|
||||
var DriveStatus = common.Shortcut{
|
||||
Service: "drive",
|
||||
Command: "+status",
|
||||
Description: "Compare a local directory with a Drive folder by content hash",
|
||||
Description: "Compare a local directory with a Drive folder by exact hash or quick modified_time",
|
||||
Risk: "read",
|
||||
Scopes: []string{"drive:drive.metadata:readonly", "drive:file:download"},
|
||||
AuthTypes: []string{"user", "bot"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "local-dir", Desc: "local root directory (relative to cwd)", Required: true},
|
||||
{Name: "folder-token", Desc: "Drive folder token", Required: true},
|
||||
{Name: "quick", Type: "bool", Desc: "compare modified_time only and skip remote downloads for files present on both sides"},
|
||||
},
|
||||
Tips: []string{
|
||||
"Only entries with type=file are compared; online docs (docx, sheet, bitable, mindnote, slides) and shortcuts are skipped.",
|
||||
"Files present on both sides are downloaded and SHA-256 hashed in memory to decide modified vs unchanged; expect noticeable I/O on large folders.",
|
||||
"Default detection=exact downloads files present on both sides and SHA-256 hashes them in memory; expect noticeable I/O on large folders.",
|
||||
"Pass --quick for the recommended fast preflight mode: it compares local mtime with Drive modified_time, skips remote downloads, and reports detection=quick as a best-effort diff.",
|
||||
},
|
||||
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
@@ -80,14 +99,22 @@ var DriveStatus = common.Shortcut{
|
||||
return nil
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
desc := "Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256."
|
||||
if runtime.Bool("quick") {
|
||||
desc = "Walk --local-dir, recursively list --folder-token, and compare local mtime with Drive modified_time for files present on both sides without downloading remote bytes."
|
||||
}
|
||||
return common.NewDryRunAPI().
|
||||
Desc("Walk --local-dir, recursively list --folder-token, and download files present on both sides to compare SHA-256.").
|
||||
Desc(desc).
|
||||
GET("/open-apis/drive/v1/files").
|
||||
Set("folder_token", runtime.Str("folder-token"))
|
||||
},
|
||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||
localDir := strings.TrimSpace(runtime.Str("local-dir"))
|
||||
folderToken := strings.TrimSpace(runtime.Str("folder-token"))
|
||||
detection := driveStatusDetectionExact
|
||||
if runtime.Bool("quick") {
|
||||
detection = driveStatusDetectionQuick
|
||||
}
|
||||
|
||||
// Resolve --local-dir to its canonical absolute path before walking.
|
||||
// SafeInputPath fully evaluates symlinks across the entire path,
|
||||
@@ -112,7 +139,7 @@ var DriveStatus = common.Shortcut{
|
||||
}
|
||||
|
||||
fmt.Fprintf(runtime.IO().ErrOut, "Walking local: %s\n", localDir)
|
||||
localHashes, err := walkLocalForStatus(runtime, safeRoot, cwdCanonical)
|
||||
localFiles, err := walkLocalForStatus(safeRoot, cwdCanonical)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -130,30 +157,42 @@ var DriveStatus = common.Shortcut{
|
||||
// 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))
|
||||
remoteFiles := make(map[string]driveStatusRemoteFile, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.Type == driveTypeFile {
|
||||
remoteFiles[entry.RelPath] = entry.FileToken
|
||||
remoteFiles[entry.RelPath] = driveStatusRemoteFile{FileToken: entry.FileToken, ModifiedTime: entry.ModifiedTime}
|
||||
}
|
||||
}
|
||||
|
||||
paths := mergeStatusPaths(localHashes, remoteFiles)
|
||||
paths := mergeStatusPaths(localFiles, remoteFiles)
|
||||
|
||||
var newLocal, newRemote, modified, unchanged []driveStatusEntry
|
||||
for _, relPath := range paths {
|
||||
localHash, hasLocal := localHashes[relPath]
|
||||
remoteToken, hasRemote := remoteFiles[relPath]
|
||||
localFile, hasLocal := localFiles[relPath]
|
||||
remoteFile, hasRemote := remoteFiles[relPath]
|
||||
switch {
|
||||
case hasLocal && !hasRemote:
|
||||
newLocal = append(newLocal, driveStatusEntry{RelPath: relPath})
|
||||
case !hasLocal && hasRemote:
|
||||
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteToken})
|
||||
newRemote = append(newRemote, driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken})
|
||||
default:
|
||||
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteToken)
|
||||
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteFile.FileToken}
|
||||
if detection == driveStatusDetectionQuick {
|
||||
if driveStatusShouldTreatAsUnchangedQuick(remoteFile.ModifiedTime, localFile.ModTime) {
|
||||
unchanged = append(unchanged, entry)
|
||||
} else {
|
||||
modified = append(modified, entry)
|
||||
}
|
||||
continue
|
||||
}
|
||||
localHash, err := hashLocalForStatus(runtime, localFile.PathToCwd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
remoteHash, err := hashRemoteForStatus(ctx, runtime, remoteFile.FileToken)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entry := driveStatusEntry{RelPath: relPath, FileToken: remoteToken}
|
||||
if localHash == remoteHash {
|
||||
unchanged = append(unchanged, entry)
|
||||
} else {
|
||||
@@ -163,6 +202,7 @@ var DriveStatus = common.Shortcut{
|
||||
}
|
||||
|
||||
runtime.Out(map[string]interface{}{
|
||||
"detection": detection,
|
||||
"new_local": emptyIfNil(newLocal),
|
||||
"new_remote": emptyIfNil(newRemote),
|
||||
"modified": emptyIfNil(modified),
|
||||
@@ -180,8 +220,8 @@ var DriveStatus = common.Shortcut{
|
||||
// hit, we report rel_path relative to root for the JSON output, and
|
||||
// convert the absolute path to a cwd-relative form so FileIO.Open's
|
||||
// SafeInputPath check (which rejects absolute paths) still applies.
|
||||
func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical string) (map[string]string, error) {
|
||||
files := make(map[string]string)
|
||||
func walkLocalForStatus(root, cwdCanonical string) (map[string]driveStatusLocalFile, error) {
|
||||
files := make(map[string]driveStatusLocalFile)
|
||||
// FileIO has no walker today and shortcuts can't import internal/vfs.
|
||||
// The walk root is the canonical absolute path returned by
|
||||
// validate.SafeInputPath, so it is no longer a symlink itself, and
|
||||
@@ -202,11 +242,11 @@ func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical strin
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sum, err := hashLocalForStatus(runtime, relToCwd)
|
||||
info, err := d.Info()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
files[filepath.ToSlash(rel)] = sum
|
||||
files[filepath.ToSlash(rel)] = driveStatusLocalFile{PathToCwd: relToCwd, ModTime: info.ModTime()}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
@@ -215,6 +255,11 @@ func walkLocalForStatus(runtime *common.RuntimeContext, root, cwdCanonical strin
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func driveStatusShouldTreatAsUnchangedQuick(remoteModified string, local time.Time) bool {
|
||||
cmp, ok := compareDriveRemoteModifiedToLocal(remoteModified, local)
|
||||
return ok && cmp == 0
|
||||
}
|
||||
|
||||
func hashLocalForStatus(runtime *common.RuntimeContext, path string) (string, error) {
|
||||
f, err := runtime.FileIO().Open(path)
|
||||
if err != nil {
|
||||
@@ -244,7 +289,7 @@ func hashRemoteForStatus(ctx context.Context, runtime *common.RuntimeContext, fi
|
||||
return hex.EncodeToString(h.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func mergeStatusPaths(local, remote map[string]string) []string {
|
||||
func mergeStatusPaths(local map[string]driveStatusLocalFile, remote map[string]driveStatusRemoteFile) []string {
|
||||
seen := make(map[string]struct{}, len(local)+len(remote))
|
||||
for p := range local {
|
||||
seen[p] = struct{}{}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/cmdutil"
|
||||
"github.com/larksuite/cli/internal/httpmock"
|
||||
@@ -105,6 +106,9 @@ func TestDriveStatusCategorizesByHash(t *testing.T) {
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"detection": "exact"`) {
|
||||
t.Fatalf("output missing detection=exact\noutput: %s", out)
|
||||
}
|
||||
checks := []struct {
|
||||
bucket string
|
||||
path string
|
||||
@@ -134,6 +138,152 @@ func TestDriveStatusCategorizesByHash(t *testing.T) {
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDriveStatusQuickCategorizesByModifiedTimeWithoutDownloads(t *testing.T) {
|
||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||
|
||||
tmpDir := t.TempDir()
|
||||
withDriveWorkingDir(t, tmpDir)
|
||||
|
||||
if err := os.MkdirAll("local/sub", 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/a.txt", []byte("local-a"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a.txt: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/b.txt", []byte("local-b"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile b.txt: %v", err)
|
||||
}
|
||||
if err := os.WriteFile("local/sub/c.txt", []byte("local-c"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile sub/c.txt: %v", err)
|
||||
}
|
||||
|
||||
matchTime := time.Unix(1715594880, 0)
|
||||
changedTime := time.Unix(1715594940, 0)
|
||||
if err := os.Chtimes("local/a.txt", matchTime, matchTime); err != nil {
|
||||
t.Fatalf("Chtimes a.txt: %v", err)
|
||||
}
|
||||
if err := os.Chtimes("local/sub/c.txt", changedTime, changedTime); err != nil {
|
||||
t.Fatalf("Chtimes sub/c.txt: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "1715594880"},
|
||||
map[string]interface{}{"token": "tok_sub", "name": "sub", "type": "folder"},
|
||||
map[string]interface{}{"token": "tok_d", "name": "d.txt", "type": "file", "modified_time": "1715595000"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=tok_sub",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_c", "name": "c.txt", "type": "file", "modified_time": "1715594880"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--quick",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"detection": "quick"`) {
|
||||
t.Fatalf("output missing detection=quick\noutput: %s", out)
|
||||
}
|
||||
checks := []struct {
|
||||
bucket string
|
||||
path string
|
||||
token string
|
||||
}{
|
||||
{"new_local", "b.txt", ""},
|
||||
{"new_remote", "d.txt", "tok_d"},
|
||||
{"modified", "sub/c.txt", "tok_c"},
|
||||
{"unchanged", "a.txt", "tok_a"},
|
||||
}
|
||||
for _, c := range checks {
|
||||
if !strings.Contains(out, `"`+c.bucket+`":`) {
|
||||
t.Errorf("output missing bucket %q\noutput: %s", c.bucket, out)
|
||||
}
|
||||
if !strings.Contains(out, `"rel_path": "`+c.path+`"`) {
|
||||
t.Errorf("output missing rel_path %q (expected in %s)\noutput: %s", c.path, c.bucket, out)
|
||||
}
|
||||
if c.token != "" && !strings.Contains(out, `"file_token": "`+c.token+`"`) {
|
||||
t.Errorf("output missing file_token %q (expected in %s)\noutput: %s", c.token, c.bucket, out)
|
||||
}
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func TestDriveStatusQuickMarksUntrustedTimestampAsModified(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("local/a.txt", []byte("local"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile a.txt: %v", err)
|
||||
}
|
||||
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "GET",
|
||||
URL: "folder_token=folder_root",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0, "msg": "ok",
|
||||
"data": map[string]interface{}{
|
||||
"files": []interface{}{
|
||||
map[string]interface{}{"token": "tok_a", "name": "a.txt", "type": "file", "modified_time": "not-a-timestamp"},
|
||||
},
|
||||
"has_more": false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
err := mountAndRunDrive(t, DriveStatus, []string{
|
||||
"+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "folder_root",
|
||||
"--quick",
|
||||
"--as", "bot",
|
||||
}, f, stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v\nstdout: %s", err, stdout.String())
|
||||
}
|
||||
|
||||
out := stdout.String()
|
||||
if !strings.Contains(out, `"detection": "quick"`) {
|
||||
t.Fatalf("output missing detection=quick\noutput: %s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"modified":`) || !strings.Contains(out, `"rel_path": "a.txt"`) {
|
||||
t.Fatalf("invalid remote modified_time must fall back to modified\noutput: %s", out)
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
// TestDriveStatusPaginatesRemoteListing pins multi-page handling end-to-end
|
||||
// AND the dual-field tolerance of common.PaginationMeta. Page 1 surfaces
|
||||
// `next_page_token` (Drive's historical name); page 2 surfaces `page_token`
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/output"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
@@ -195,6 +196,9 @@ const (
|
||||
driveDuplicateRemoteOldest = "oldest"
|
||||
)
|
||||
|
||||
// sortRemoteFiles orders duplicate Drive files according to the conflict
|
||||
// strategy, using parsed Drive timestamps so mixed second/millisecond/
|
||||
// microsecond epochs compare by actual time rather than raw integer width.
|
||||
func sortRemoteFiles(files []driveRemoteEntry, strategy string) {
|
||||
sort.SliceStable(files, func(i, j int) bool {
|
||||
a, b := files[i], files[j]
|
||||
@@ -226,16 +230,61 @@ func sortRemoteFiles(files []driveRemoteEntry, strategy string) {
|
||||
})
|
||||
}
|
||||
|
||||
// compareDriveTimes compares two Drive epoch strings after normalizing their
|
||||
// unit (seconds, milliseconds, or microseconds) into time.Time values.
|
||||
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 {
|
||||
at, _, aOK := parseDriveEpoch(a)
|
||||
bt, _, bOK := parseDriveEpoch(b)
|
||||
if !aOK || !bOK {
|
||||
return 0, false
|
||||
}
|
||||
switch {
|
||||
case av < bv:
|
||||
case at.Before(bt):
|
||||
return -1, true
|
||||
case av > bv:
|
||||
case at.After(bt):
|
||||
return 1, true
|
||||
default:
|
||||
return 0, true
|
||||
}
|
||||
}
|
||||
|
||||
func parseDriveEpoch(raw string) (time.Time, time.Duration, bool) {
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, 0, false
|
||||
}
|
||||
// Drive timestamps are epoch strings. The API currently returns
|
||||
// milliseconds, but tests and older payloads may still use seconds.
|
||||
// Infer the unit conservatively from magnitude and compare local mtimes
|
||||
// at the same resolution so sub-second filesystem noise does not force
|
||||
// a transfer in smart mode.
|
||||
switch {
|
||||
case v > 1e14 || v < -1e14:
|
||||
return time.UnixMicro(v), time.Microsecond, true
|
||||
case v > 1e11 || v < -1e11:
|
||||
return time.UnixMilli(v), time.Millisecond, true
|
||||
default:
|
||||
return time.Unix(v, 0), time.Second, true
|
||||
}
|
||||
}
|
||||
|
||||
// compareDriveRemoteModifiedToLocal compares one Drive modified_time string to a
|
||||
// local file mtime.
|
||||
// - returns -1 when remote < local
|
||||
// - returns 0 when remote == local at the remote timestamp resolution
|
||||
// - returns 1 when remote > local
|
||||
//
|
||||
// The bool reports whether the remote timestamp was parseable.
|
||||
func compareDriveRemoteModifiedToLocal(remoteModified string, local time.Time) (int, bool) {
|
||||
remoteTime, resolution, ok := parseDriveEpoch(remoteModified)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
localAtRemoteResolution := local.Truncate(resolution)
|
||||
switch {
|
||||
case remoteTime.Before(localAtRemoteResolution):
|
||||
return -1, true
|
||||
case remoteTime.After(localAtRemoteResolution):
|
||||
return 1, true
|
||||
default:
|
||||
return 0, true
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// mustMarshalDryRun marshals v to a JSON string, calling t.Fatalf on error.
|
||||
func mustMarshalDryRun(t *testing.T, v interface{}) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -25,6 +26,9 @@ func mustMarshalDryRun(t *testing.T, v interface{}) string {
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// newTestRuntimeContext builds a *common.RuntimeContext backed by a cobra
|
||||
// command whose flags are populated from the provided string and bool maps,
|
||||
// for unit-testing shortcut bodies, validators, and dry-run shapes.
|
||||
func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -55,6 +59,9 @@ func newTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlag
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// newMessagesSearchTestRuntimeContext is the messages-search variant of
|
||||
// newTestRuntimeContext: registers the search-specific --page-size flag
|
||||
// before applying caller-provided values.
|
||||
func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]string, boolFlags map[string]bool) *common.RuntimeContext {
|
||||
t.Helper()
|
||||
|
||||
@@ -86,6 +93,8 @@ func newMessagesSearchTestRuntimeContext(t *testing.T, stringFlags map[string]st
|
||||
return &common.RuntimeContext{Cmd: cmd}
|
||||
}
|
||||
|
||||
// TestBuildCreateChatBody verifies the request body assembled when every
|
||||
// flag is populated, including the default chat_mode="group".
|
||||
func TestBuildCreateChatBody(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"type": "public",
|
||||
@@ -94,11 +103,13 @@ func TestBuildCreateChatBody(t *testing.T) {
|
||||
"users": "ou_1, ou_2",
|
||||
"bots": "cli_1, cli_2",
|
||||
"owner": "ou_owner",
|
||||
"chat-mode": "group",
|
||||
}, nil)
|
||||
|
||||
got := buildCreateChatBody(runtime)
|
||||
want := map[string]interface{}{
|
||||
"chat_type": "public",
|
||||
"chat_mode": "group",
|
||||
"name": "Team Chat",
|
||||
"description": "daily sync",
|
||||
"user_id_list": []string{
|
||||
@@ -116,6 +127,43 @@ func TestBuildCreateChatBody(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCreateChatBody_TopicMode verifies that --chat-mode topic produces
|
||||
// chat_mode="topic" in the request body, the topic-chat creation path.
|
||||
func TestBuildCreateChatBody_TopicMode(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"type": "public",
|
||||
"name": "Topic Group",
|
||||
"chat-mode": "topic",
|
||||
}, nil)
|
||||
|
||||
got := buildCreateChatBody(runtime)
|
||||
want := map[string]interface{}{
|
||||
"chat_type": "public",
|
||||
"chat_mode": "topic",
|
||||
"name": "Topic Group",
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("buildCreateChatBody() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildCreateChatBody_EmptyChatModeFallsBack pins the defensive fallback:
|
||||
// explicit `--chat-mode ""` slips past validateEnumFlags (which skips empty
|
||||
// values), but buildCreateChatBody must still emit chat_mode="group" rather
|
||||
// than an empty string with unspecified server semantics.
|
||||
func TestBuildCreateChatBody_EmptyChatModeFallsBack(t *testing.T) {
|
||||
runtime := newTestRuntimeContext(t, map[string]string{
|
||||
"type": "public",
|
||||
"name": "Fallback Test",
|
||||
"chat-mode": "",
|
||||
}, nil)
|
||||
|
||||
got := buildCreateChatBody(runtime)
|
||||
if got["chat_mode"] != "group" {
|
||||
t.Fatalf("buildCreateChatBody() chat_mode = %#v, want \"group\"", got["chat_mode"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitMembers verifies the delegation wrapper; core logic is tested in TestSplitCSV. [#17]
|
||||
func TestSplitMembers(t *testing.T) {
|
||||
got := common.SplitCSV(" ou_1, ,ou_2 ,, ou_3 ")
|
||||
@@ -591,10 +639,12 @@ func TestMessagesSearchPaginationConfig(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestShortcutDryRunShapes verifies that each shortcut's DryRun function
|
||||
// produces the expected API path, query parameters, and request body.
|
||||
func TestShortcutDryRunShapes(t *testing.T) {
|
||||
t.Run("ImChatCreate dry run includes params and body", func(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "test"}
|
||||
for _, name := range []string{"type", "name", "users", "owner"} {
|
||||
for _, name := range []string{"type", "name", "users", "owner", "chat-mode"} {
|
||||
cmd.Flags().String(name, "", "")
|
||||
}
|
||||
cmd.Flags().Bool("set-bot-manager", false, "")
|
||||
@@ -604,9 +654,10 @@ func TestShortcutDryRunShapes(t *testing.T) {
|
||||
_ = cmd.Flags().Set("users", "ou_1,ou_2")
|
||||
_ = cmd.Flags().Set("owner", "ou_owner")
|
||||
_ = cmd.Flags().Set("set-bot-manager", "true")
|
||||
_ = cmd.Flags().Set("chat-mode", "group")
|
||||
runtime := common.TestNewRuntimeContextWithIdentity(cmd, nil, "bot")
|
||||
got := mustMarshalDryRun(t, ImChatCreate.DryRun(context.Background(), runtime))
|
||||
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) {
|
||||
if !strings.Contains(got, `"/open-apis/im/v1/chats"`) || !strings.Contains(got, `"set_bot_manager":true`) || !strings.Contains(got, `"chat_type":"public"`) || !strings.Contains(got, `"chat_mode":"group"`) {
|
||||
t.Fatalf("ImChatCreate.DryRun() = %s", got)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -16,10 +16,14 @@ import (
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
// ImChatCreate is the +chat-create shortcut: creates a group chat or topic
|
||||
// chat via POST /open-apis/im/v1/chats. Supports user and bot identities;
|
||||
// --chat-mode selects group (default) or topic; --type selects private
|
||||
// (default) or public; --users/--bots invite members at creation.
|
||||
var ImChatCreate = common.Shortcut{
|
||||
Service: "im",
|
||||
Command: "+chat-create",
|
||||
Description: "Create a group chat; user/bot; creates private/public chats, invites users/bots, optionally sets bot manager",
|
||||
Description: "Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager",
|
||||
Risk: "write",
|
||||
UserScopes: []string{"im:chat:create_by_user"},
|
||||
BotScopes: []string{"im:chat:create"},
|
||||
@@ -32,6 +36,7 @@ var ImChatCreate = common.Shortcut{
|
||||
{Name: "bots", Desc: "comma-separated bot app IDs (cli_xxx) to invite, max 5"},
|
||||
{Name: "owner", Desc: "owner open_id (ou_xxx); defaults to bot (--as bot) or authorized user (--as user)"},
|
||||
{Name: "type", Default: "private", Desc: "chat type", Enum: []string{"private", "public"}},
|
||||
{Name: "chat-mode", Default: "group", Desc: "group mode (\"topic\" creates a topic chat; differs from a normal group in topic-message mode)", Enum: []string{"group", "topic"}},
|
||||
{Name: "set-bot-manager", Type: "bool", Desc: "set the bot that creates this chat as manager (bot identity only)"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||
@@ -141,9 +146,18 @@ var ImChatCreate = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
// buildCreateChatBody assembles the POST /open-apis/im/v1/chats request
|
||||
// body. chat_mode is always emitted; an empty value (which can slip past
|
||||
// validateEnumFlags, since that helper skips empty strings) is pinned to
|
||||
// "group" so the wire never carries an unspecified chat_mode value.
|
||||
func buildCreateChatBody(runtime *common.RuntimeContext) map[string]interface{} {
|
||||
chatMode := runtime.Str("chat-mode")
|
||||
if chatMode == "" {
|
||||
chatMode = "group"
|
||||
}
|
||||
body := map[string]interface{}{
|
||||
"chat_type": runtime.Str("type"),
|
||||
"chat_mode": chatMode,
|
||||
}
|
||||
if name := runtime.Str("name"); name != "" {
|
||||
body["name"] = name
|
||||
|
||||
@@ -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). 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. |
|
||||
| [`+status`](references/lark-drive-status.md) | Compare a local directory with a Drive folder by exact SHA-256 hash by default, or use `--quick` for a best-effort modified-time diff that skips remote downloads; reports `new_local` / `new_remote` / `modified` / `unchanged` plus `detection=exact|quick`. 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) | File-level Drive → local mirror. Duplicate remote `rel_path` conflicts fail by default; for duplicate files, `rename` downloads all copies with stable hashed suffixes, while `newest` / `oldest` pick one. `--if-exists` supports `overwrite` / `smart` / `skip` (`smart` is a best-effort modified-time incremental mode for repeat syncs). `--delete-local` requires `--yes`, only removes regular files, and is skipped after item failures. `--local-dir` must stay inside 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). 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. |
|
||||
| [`+push`](references/lark-drive-push.md) | File-level local → Drive mirror. Duplicate remote `rel_path` conflicts fail by default; `newest` / `oldest` only apply to duplicate files when you explicitly want to target one remote file. `--if-exists` supports `skip` / `smart` / `overwrite` (`smart` skips files whose remote `modified_time` is already up to date, but falls through to the same overwrite path when the remote is older, so it inherits overwrite's rollout caveat). `--delete-remote` requires `--yes`. `--local-dir` must stay inside 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) |
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `summary.downloaded` | 成功下载的文件数 |
|
||||
| `summary.skipped` | 按 `--if-exists=skip` 跳过的文件数 |
|
||||
| `summary.skipped` | 因 `--if-exists=skip` 或 `--if-exists=smart` 命中“无需下载”而跳过的文件数 |
|
||||
| `summary.failed` | 下载或写盘失败的文件数 |
|
||||
| `summary.deleted_local` | 启用 `--delete-local --yes` 时删除的本地文件数 |
|
||||
| `items[]` | 每个文件的明细(`rel_path` / `file_token` / `source_id` / `action` / 失败时的 `error`) |
|
||||
@@ -38,6 +38,10 @@
|
||||
# 基础用法 —— 把云端 fldcXXX 镜像到 ./repo
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx
|
||||
|
||||
# 推荐的重复同步用法:smart 会按 modified_time 跳过已经对齐的本地文件
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists smart
|
||||
|
||||
# 已存在的本地文件保持不动
|
||||
lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists skip
|
||||
@@ -58,7 +62,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` |
|
||||
| `--if-exists` | 否 | enum | 本地文件已存在时的策略:`overwrite`(**默认**,Drive 作为权威源时使用)/ `smart`(**推荐用于重复增量同步**;当本地 mtime 已与远端 `modified_time` 匹配或更新时跳过下载)/ `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 阶段被拒绝 |
|
||||
@@ -67,7 +71,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。
|
||||
- 已存在的本地文件按 `--if-exists` 决定 `overwrite` / `smart` / `skip`。其中 **`smart` 是推荐的重复同步模式**:只要本地 mtime 在远端时间精度下已经等于或晚于远端 `modified_time`,就跳过下载;时间戳缺失/非法时会退回安全路径继续下载,不会盲跳。想做 `keep-both` 这类的仍需自己改名再 pull。
|
||||
- 云端同名冲突默认失败;只有“冲突全是 `type=file`”且传了 `--on-duplicate-remote rename|newest|oldest` 时才会继续。
|
||||
|
||||
## --delete-local 的安全行为
|
||||
@@ -106,8 +110,8 @@ lark-cli drive +pull --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
|
||||
## 性能注意
|
||||
|
||||
- 下载流量 ≈ 云端待下载文件的总字节数。pull 是**全量**写盘 —— 跟 `+status` 不一样,不会跳过"内容相同"的文件(status 是按 hash 比较,pull 是按 `--if-exists`),所以一次跑可能很重。
|
||||
- 想避免重跑全量,可以先 `+status` 找出 `new_remote` 和 `modified`,再只对这些文件单独 `+download`。
|
||||
- 默认 `overwrite` 下,重复跑会重新下载所有命中的同名文件;`skip` 下则完全不碰已存在文件;**`smart` 下才会按 `modified_time` 跳过已经对齐的本地文件**,适合重复增量同步。
|
||||
- 想更精细地控制下载量,可以先 `+status` 找出 `new_remote` 和 `modified`,再只对这些文件单独 `+download`;或者直接在整目录同步时使用 `--if-exists smart`。
|
||||
- 大文件会用 SDK 的流式下载(不会把整个 body 读进内存),但本地磁盘空间需要够。
|
||||
|
||||
## 所需 scope
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `summary.uploaded` | 成功新建或覆盖的文件数 |
|
||||
| `summary.skipped` | 按 `--if-exists=skip` 跳过的文件数 |
|
||||
| `summary.skipped` | 因 `--if-exists=skip` 或 `--if-exists=smart` 命中“无需传输”而跳过的文件数 |
|
||||
| `summary.failed` | 上传 / 覆盖 / 建目录 / 删除失败的条目数;**只要不为 0,命令就以非零状态退出**(结构化 `items[]` 仍在 stdout 上) |
|
||||
| `summary.deleted_remote` | 启用 `--delete-remote --yes` 时删除的云端文件数 |
|
||||
| `items[]` | 每个条目的明细(`rel_path` / `file_token` / `action` / 覆盖时的 `version` / `size_bytes` / 失败时的 `error`) |
|
||||
@@ -36,10 +36,14 @@
|
||||
## 命令
|
||||
|
||||
```bash
|
||||
# 基础用法 —— 把本地 ./repo 增量推送到云端 fldcXXX
|
||||
# 基础用法 —— 把本地 ./repo 推送到云端 fldcXXX
|
||||
# 默认 --if-exists=skip:已经存在的远端文件保持不动,只新增、不覆盖。
|
||||
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx
|
||||
|
||||
# 重复同步时可用 smart 做增量优化:它会按 modified_time 跳过已对齐的远端文件;但如果远端更旧,仍会继续走覆盖路径
|
||||
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists smart
|
||||
|
||||
# 显式覆盖远端同名文件(依赖 upload_all 的灰度协议字段,详见下文"覆盖语义")
|
||||
lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
--if-exists overwrite
|
||||
@@ -62,7 +66,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`(依赖灰度后端协议,详见"覆盖语义") |
|
||||
| `--if-exists` | 否 | enum | 远端文件已存在时的策略:`skip`(**默认**,安全)/ `smart`(用于重复增量同步;当远端 `modified_time` 已匹配或更新时跳过上传,否则继续走覆盖路径)/ `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 阶段被拒绝 |
|
||||
@@ -71,13 +75,15 @@ 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。
|
||||
- 已存在的远端文件按 `--if-exists` 决定 `overwrite` / `smart` / `skip`。其中 `smart` 是**增量优化模式**:只要远端 `modified_time` 在同等时间精度下已经等于或晚于本地 mtime,就跳过上传;时间戳缺失/非法时会退回安全路径继续上传,不会盲跳。**但如果远端更旧,`smart` 会继续走和 `overwrite` 相同的覆盖路径,因此也继承同样的 rollout / version 返回 caveat。** 想做 `keep-both` 这类的仍需自行改名再 push。
|
||||
- 云端同名冲突默认失败;只有“冲突全是 `type=file`”且传了 `--on-duplicate-remote newest|oldest` 时才会选择一个远端文件继续。启用 `--delete-remote` 时,未被选中的 duplicate sibling 也会被删除,最终远端只保留一个被选中的文件副本;只有在 `--if-exists=overwrite` 成功时,才能保证该副本内容与本地对齐。
|
||||
|
||||
## 覆盖语义
|
||||
|
||||
`--if-exists=overwrite` 走 `POST /open-apis/drive/v1/files/upload_all`,并在 form 中带上现有文件的 `file_token`,由后端原地更新内容并返回新版本号。`items[].version` 字段会回填该版本号。
|
||||
|
||||
`--if-exists=smart` 是给“重复跑同步”的场景增加的增量优化:当远端 `modified_time` 在同等时间精度下已经等于或晚于本地 mtime 时,命令会把该文件计为 `skipped`;时间戳缺失、非法或更旧时,则继续走正常上传/覆盖路径。**也就是说,只要 smart 判定“远端不够新”,它就会进入与 `--if-exists=overwrite` 相同的覆盖实现,因此在未 rollout version 字段的 tenant 上仍可能非零失败。**
|
||||
|
||||
> **为什么默认是 `skip` 而不是 `overwrite`:** `upload_all` 接受 `file_token` 字段、并在响应里返回 `version` 是设计文档(Drive 同步盘)规定的协议;此后端尚在灰度发布。在还未开通该字段的 tenant 上,`--if-exists=overwrite` 会因"无 version 返回"而把对应文件标成 `failed`,整次 `+push` 也会因此非零退出。所以默认值故意定为 `skip`:第一次往一个已经有内容的目录里 push,不会因为协议没到位就把整次运行打挂;要真的覆盖远端,必须显式带 `--if-exists overwrite`。新建上传不依赖该字段,不受影响。
|
||||
|
||||
大文件(>20MB)会自动切到三段式 `upload_prepare` / `upload_part` / `upload_finish`;该路径下 `version` 暂未在响应中返回,覆盖结果中 `items[].version` 会留空,但 `file_token` 与 `action: overwritten` 仍会正确产出。
|
||||
@@ -122,8 +128,8 @@ lark-cli drive +push --local-dir ./repo --folder-token fldcnxxxxxxxxx \
|
||||
|
||||
## 性能注意
|
||||
|
||||
- 上传流量 ≈ 本地待上传文件的总字节数。push 是**全量**上传 —— 跟 `+status` 不一样,不会按 hash 跳过"内容相同"的文件(status 是按 hash 比较,push 是按 `--if-exists`),所以一次跑可能很重。
|
||||
- 想避免重跑全量,可以先 `+status` 找出 `new_local` 和 `modified`,再只对这些文件单独上传 / 覆盖。
|
||||
- 默认 `skip` 下,已存在的远端文件一律不碰;`overwrite` 下,重复跑会重传所有命中的同名文件;`smart` 下会按 `modified_time` 跳过已对齐的远端文件,但对“远端更旧”的文件仍会进入覆盖路径,因此它减少的是**不必要的重传**,不是把覆盖风险完全拿掉。
|
||||
- 想更精细地控制传输量,可以先 `+status` 找出 `new_local` 和 `modified`,再只对这些文件单独上传 / 覆盖;或者直接在整目录同步时使用 `--if-exists smart`。
|
||||
- 大文件会用三段式分片上传(不会把整个 body 读进内存),但本地磁盘和上行带宽需要够。
|
||||
|
||||
## 所需 scope
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
|
||||
|
||||
按 SHA-256 内容哈希比较本地目录与飞书云空间文件夹,输出四类差异:
|
||||
按 **精确 SHA-256**(默认)或 **快速 modified_time**(`--quick`)比较本地目录与飞书云空间文件夹,输出四类差异:
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
@@ -12,7 +12,10 @@
|
||||
| `modified` | 双端都存在但 hash 不一致 |
|
||||
| `unchanged` | 双端都存在且 hash 一致 |
|
||||
|
||||
只读命令:流式 hash,不下载落盘;但双端都有的文件会从云端拉一份字节流过来在内存里算 hash,大目录 / 大文件会有可观的网络流量。
|
||||
只读命令:
|
||||
|
||||
- 默认 `detection=exact`:双端都有的文件会从云端拉一份字节流过来在内存里算 hash,不下载落盘,但大目录 / 大文件会有可观的网络流量。
|
||||
- 传 `--quick` 后 `detection=quick`:只比较本地 mtime 与远端 `modified_time`,**不下载远端文件内容**,适合先做快速预检查;它是 best-effort,不等同于严格内容一致性判断。
|
||||
|
||||
## 远端同名文件冲突
|
||||
|
||||
@@ -26,6 +29,12 @@ lark-cli drive +status \
|
||||
--local-dir ./repo \
|
||||
--folder-token fldcnxxxxxxxxx
|
||||
|
||||
# 快速模式 —— 只比较 modified_time,不下载远端文件内容
|
||||
lark-cli drive +status \
|
||||
--local-dir ./repo \
|
||||
--folder-token fldcnxxxxxxxxx \
|
||||
--quick
|
||||
|
||||
# 只看 hash 不一致的项(结合 --jq 过滤)
|
||||
lark-cli drive +status \
|
||||
--local-dir ./repo \
|
||||
@@ -39,6 +48,7 @@ lark-cli drive +status \
|
||||
|------|------|------|------|
|
||||
| `--local-dir` | 是 | path | 本地根目录(**必须是 cwd 的相对路径**;绝对路径或逃逸到 cwd 外的相对路径会被 CLI 直接拒绝) |
|
||||
| `--folder-token` | 是 | string | Drive 文件夹 token |
|
||||
| `--quick` | 否 | bool | 快速模式:只比较本地 mtime 与远端 `modified_time`,跳过远端下载和 SHA-256 计算;输出里的 `detection` 会变成 `quick` |
|
||||
|
||||
## 输出 schema
|
||||
|
||||
@@ -46,6 +56,7 @@ lark-cli drive +status \
|
||||
|
||||
```json
|
||||
{
|
||||
"detection": "exact",
|
||||
"new_local": [{"rel_path": "..."}],
|
||||
"new_remote": [{"rel_path": "...", "file_token": "..."}],
|
||||
"modified": [{"rel_path": "...", "file_token": "..."}],
|
||||
@@ -53,6 +64,11 @@ lark-cli drive +status \
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `detection=exact`:默认模式,双端都有的文件会下载远端字节流并做 SHA-256 比较。
|
||||
- `detection=quick`:`--quick` 模式,只按本地 mtime 与远端 `modified_time` 做 best-effort 判断。
|
||||
|
||||
`rel_path` 始终用 `/` 作为分隔符(跨平台一致),相对于 `--local-dir` 或 `--folder-token` 的根。仅本地存在时没有 `file_token` 字段。
|
||||
|
||||
远端同名文件冲突时:
|
||||
@@ -84,6 +100,7 @@ lark-cli drive +status \
|
||||
- 子文件夹会递归遍历;rel_path 形如 `sub1/sub2/file.txt`。
|
||||
- 多个远端条目映射到同一个 rel_path 时不做隐式选择,默认失败。
|
||||
- 本地侧只比对常规文件(regular file);符号链接、设备文件等被忽略。
|
||||
- `--quick` 模式下,双端都有的文件只在 **远端时间精度** 下比较 `modified_time` / 本地 mtime:相等才记为 `unchanged`,否则记为 `modified`;远端时间戳缺失或非法时,走保守路径记为 `modified`,不会盲判 `unchanged`。
|
||||
|
||||
## 范围限制
|
||||
|
||||
@@ -99,9 +116,10 @@ lark-cli drive +status \
|
||||
|
||||
## 性能注意
|
||||
|
||||
- `unchanged` + `modified` 的总字节数 = 本次需从云端下载的流量。100GB 的双端共享内容意味着 100GB 网络往返。
|
||||
- 默认 `detection=exact` 下,`unchanged` + `modified` 的总字节数 = 本次需从云端下载的流量。100GB 的双端共享内容意味着 100GB 网络往返。
|
||||
- `--quick` / `detection=quick` 下,不会下载双端共有文件的远端内容,执行时间更接近 `O(文件数量)`,而不是 `O(总文件大小)`。
|
||||
- 仅一侧存在的文件不会被下载。
|
||||
- Hash 计算在内存里流式做(io.Copy → sha256.New),不会把云端文件落到磁盘。
|
||||
- 默认模式的 hash 计算在内存里流式做(io.Copy → sha256.New),不会把云端文件落到磁盘。
|
||||
|
||||
## 所需 scope
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-im
|
||||
version: 1.0.0
|
||||
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、管理标记数据时使用。"
|
||||
description: "飞书即时通讯:收发消息和管理群聊。发送和回复消息、搜索聊天记录、管理群聊成员、上传下载图片和文件(支持大文件分片下载)、管理表情回复。当用户需要发消息、查看或搜索聊天记录、下载聊天中的文件、查看群成员、搜索群、创建群聊或话题群、管理标记数据时使用。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -68,7 +68,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli im +<verb> [flags]`)。
|
||||
|
||||
| Shortcut | 说明 |
|
||||
|----------|------|
|
||||
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat; user/bot; creates private/public chats, invites users/bots, optionally sets bot manager |
|
||||
| [`+chat-create`](references/lark-im-chat-create.md) | Create a group chat or topic chat; user/bot; --chat-mode group|topic; private/public; invites users/bots; optionally sets bot manager |
|
||||
| [`+chat-messages-list`](references/lark-im-chat-messages-list.md) | List messages in a chat or P2P conversation; user/bot; accepts --chat-id or --user-id, resolves P2P chat_id, supports time range/sort/pagination |
|
||||
| [`+chat-search`](references/lark-im-chat-search.md) | Search visible group chats by `--query` keyword and/or `--member-ids`; user/bot; e.g. look up chat_id by group name; supports type filters, sorting, and pagination |
|
||||
| [`+chat-update`](references/lark-im-chat-update.md) | Update group chat name or description; user/bot; updates a chat's name or description |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
> **Prerequisite:** Read [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) first to understand authentication, global parameters, and safety rules.
|
||||
|
||||
Create a group chat. Supports both user identity (`--as user`) and bot identity (`--as bot`). You can specify the group name, description, members (users/bots), owner, and chat type (private/public).
|
||||
Create a group chat. Supports both user identity (`--as user`) and bot identity (`--as bot`). You can specify the group name, description, members (users/bots), owner, chat type (private/public), and group mode. Set `--chat-mode topic` to create a topic chat.
|
||||
|
||||
This skill maps to the shortcut: `lark-cli im +chat-create` (internally calls `POST /open-apis/im/v1/chats`).
|
||||
|
||||
@@ -18,6 +18,9 @@ lark-cli im +chat-create --name "My Group"
|
||||
# Create a public group (name is required and must be at least 2 characters)
|
||||
lark-cli im +chat-create --name "Public Group" --type public
|
||||
|
||||
# Create a topic chat
|
||||
lark-cli im +chat-create --name "Topic Group" --chat-mode topic
|
||||
|
||||
# Specify the group owner
|
||||
lark-cli im +chat-create --name "My Group" --owner ou_xxx
|
||||
|
||||
@@ -55,12 +58,15 @@ lark-cli im +chat-create --name "My Group" --dry-run
|
||||
| `--users <ids>` | No | Up to 50, format `ou_xxx` | Comma-separated user open_ids |
|
||||
| `--bots <ids>` | No | Up to 5, format `cli_xxx` | Comma-separated bot app IDs |
|
||||
| `--owner <open_id>` | No | Format `ou_xxx` | Owner open_id (defaults to the bot when using `--as bot`, or the authorized user when using `--as user`) |
|
||||
| `--type <type>` | No | `private` (default) or `public` | Group type |
|
||||
| `--type <type>` | No | `private` (default) or `public` | Group type. Default to `private`; pass `public` only when the user explicitly asks for a discoverable/public group. |
|
||||
| `--chat-mode <mode>` | No | `group` (default) or `topic` | Group mode; `topic` creates a topic chat (not the same as `group_message_type=thread`). When the user asks for a topic chat, pass `topic` explicitly — do not rely on the default. |
|
||||
| `--set-bot-manager` | No | - | Set the creating bot as a group manager (only effective with `--as bot`) |
|
||||
| `--format json` | No | - | Output as JSON |
|
||||
| `--as <identity>` | No | `bot` or `user` | Identity type |
|
||||
| `--dry-run` | No | - | Preview the request without executing it |
|
||||
|
||||
> **`--chat-mode topic` vs "normal group with topic-message mode"**: `--chat-mode topic` here creates a 话题群 — the entire group is a topic chat. This is different from "normal group (`chat_mode=group`) + topic-message mode (`group_message_type=thread`)". This CLI exposes only `chat_mode`; `group_message_type` is intentionally not surfaced.
|
||||
|
||||
## AI Usage Guidance
|
||||
|
||||
### When using `--as bot`
|
||||
|
||||
@@ -212,3 +212,40 @@ func TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_PullDryRunAcceptsIfExistsSmart(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")
|
||||
|
||||
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",
|
||||
"--if-exists", "smart",
|
||||
"--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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -282,3 +282,40 @@ func TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_PushDryRunAcceptsIfExistsSmart(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")
|
||||
|
||||
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",
|
||||
"--if-exists", "smart",
|
||||
"--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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,50 @@ func TestDrive_StatusDryRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDrive_StatusDryRunQuick(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")
|
||||
|
||||
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", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", "fldcnE2E001",
|
||||
"--quick",
|
||||
"--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, "api.0.url").String(); got != "/open-apis/drive/v1/files" {
|
||||
t.Fatalf("url = %q, want /open-apis/drive/v1/files\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)
|
||||
}
|
||||
desc := gjson.Get(out, "description").String()
|
||||
if !strings.Contains(desc, "modified_time") || strings.Contains(desc, "SHA-256") {
|
||||
t.Fatalf("quick description must mention modified_time and skip SHA-256 wording, got %q\nstdout:\n%s", desc, out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_StatusDryRunRejectsAbsoluteLocalDir confirms that the
|
||||
// --local-dir path validator runs in the real binary's Validate stage and
|
||||
// surfaces a structured error referencing --local-dir (not the framework
|
||||
|
||||
@@ -5,8 +5,10 @@ package drive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -183,4 +185,266 @@ func TestDrive_StatusWorkflow(t *testing.T) {
|
||||
t.Errorf("data.%s length=%d want %d\nstdout:\n%s", b.bucket, got, b.want, out)
|
||||
}
|
||||
}
|
||||
|
||||
quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--quick",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
quickResult.AssertExitCode(t, 0)
|
||||
quickResult.AssertStdoutStatus(t, true)
|
||||
|
||||
quickOut := quickResult.Stdout
|
||||
if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" {
|
||||
t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
if got := int(gjson.Get(quickOut, "data.new_local.#").Int()); got != 1 {
|
||||
t.Fatalf("quick new_local length=%d want 1\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
if got := int(gjson.Get(quickOut, "data.new_remote.#").Int()); got != 1 {
|
||||
t.Fatalf("quick new_remote length=%d want 1\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
if got := gjson.Get(quickOut, "data.new_local.0.rel_path").String(); got != "local-only.txt" {
|
||||
t.Fatalf("quick new_local path=%q want local-only.txt\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
if got := gjson.Get(quickOut, "data.new_remote.0.rel_path").String(); got != "remote-only.txt" {
|
||||
t.Fatalf("quick new_remote path=%q want remote-only.txt\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
sharedCount := int(gjson.Get(quickOut, "data.modified.#").Int() + gjson.Get(quickOut, "data.unchanged.#").Int())
|
||||
if sharedCount != 2 {
|
||||
t.Fatalf("quick shared file count=%d want 2 across modified+unchanged\nstdout:\n%s", sharedCount, quickOut)
|
||||
}
|
||||
for _, path := range []string{"unchanged.txt", "modified.txt"} {
|
||||
if !gjson.Get(quickOut, `data.modified.#(rel_path="`+path+`")`).Exists() && !gjson.Get(quickOut, `data.unchanged.#(rel_path="`+path+`")`).Exists() {
|
||||
t.Fatalf("quick output missing shared path %q\nstdout:\n%s", path, quickOut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDrive_StatusQuickWorkflow proves that --quick really follows modified_time
|
||||
// semantics on the live backend instead of silently behaving like the default
|
||||
// exact hash mode.
|
||||
//
|
||||
// The fixture intentionally makes the two shared files diverge in opposite ways:
|
||||
// - same-mtime.txt: bytes differ, mtime matches remote → quick=unchanged / exact=modified
|
||||
// - remote-newer.txt: bytes match, local mtime is older → quick=modified / exact=unchanged
|
||||
//
|
||||
// This locks in the best-effort nature of quick mode with real Drive
|
||||
// modified_time values fetched from the list API, plus the expected new_local /
|
||||
// new_remote buckets.
|
||||
func TestDrive_StatusQuickWorkflow(t *testing.T) {
|
||||
parentT := t
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
suffix := clie2e.GenerateSuffix()
|
||||
folderName := "lark-cli-e2e-drive-status-quick-" + suffix
|
||||
folderToken := createDriveFolder(t, parentT, ctx, folderName, "")
|
||||
|
||||
workDir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(workDir, "local"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir local: %v", err)
|
||||
}
|
||||
|
||||
writeLocal := func(rel, content string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(workDir, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
t.Fatalf("mkdir parent of %s: %v", rel, err)
|
||||
}
|
||||
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", rel, err)
|
||||
}
|
||||
}
|
||||
|
||||
uploadDriveFile := func(name, content string) string {
|
||||
t.Helper()
|
||||
stage := "_upload_" + name
|
||||
writeLocal(stage, content)
|
||||
t.Cleanup(func() { _ = os.Remove(filepath.Join(workDir, stage)) })
|
||||
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+upload",
|
||||
"--file", stage,
|
||||
"--folder-token", folderToken,
|
||||
"--name", name,
|
||||
},
|
||||
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)
|
||||
|
||||
parentT.Cleanup(func() {
|
||||
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
|
||||
defer cleanupCancel()
|
||||
deleteResult, deleteErr := clie2e.RunCmdWithRetry(cleanupCtx, clie2e.Request{
|
||||
Args: []string{"drive", "+delete", "--file-token", fileToken, "--type", "file", "--yes"},
|
||||
DefaultAs: "bot",
|
||||
}, clie2e.RetryOptions{})
|
||||
clie2e.ReportCleanupFailure(parentT, "delete drive file "+fileToken, deleteResult, deleteErr)
|
||||
})
|
||||
return fileToken
|
||||
}
|
||||
|
||||
tokSameMtime := uploadDriveFile("same-mtime.txt", "remote bytes A")
|
||||
tokRemoteNewer := uploadDriveFile("remote-newer.txt", "remote bytes B")
|
||||
tokRemoteOnly := uploadDriveFile("remote-only.txt", "remote only")
|
||||
|
||||
remoteFiles := listDriveFolderFilesByName(t, ctx, folderToken)
|
||||
sameMtimeRemote := remoteFiles["same-mtime.txt"]
|
||||
remoteNewer := remoteFiles["remote-newer.txt"]
|
||||
if sameMtimeRemote.ModifiedTime == "" || remoteNewer.ModifiedTime == "" {
|
||||
t.Fatalf("expected modified_time for shared remote files, got: %#v", remoteFiles)
|
||||
}
|
||||
|
||||
writeLocal("local/same-mtime.txt", "local bytes A") // bytes differ from remote
|
||||
writeLocal("local/remote-newer.txt", "remote bytes B") // bytes match remote
|
||||
writeLocal("local/local-only.txt", "local only") // local-only bucket
|
||||
|
||||
sameMtimePath := filepath.Join(workDir, "local", "same-mtime.txt")
|
||||
remoteNewerPath := filepath.Join(workDir, "local", "remote-newer.txt")
|
||||
sameMtimeAt := mustParseDriveEpochForE2E(t, sameMtimeRemote.ModifiedTime)
|
||||
remoteNewerAt := mustParseDriveEpochForE2E(t, remoteNewer.ModifiedTime)
|
||||
if err := os.Chtimes(sameMtimePath, sameMtimeAt, sameMtimeAt); err != nil {
|
||||
t.Fatalf("chtimes same-mtime.txt: %v", err)
|
||||
}
|
||||
localOlder := remoteNewerAt.Add(-2 * time.Second)
|
||||
if err := os.Chtimes(remoteNewerPath, localOlder, localOlder); err != nil {
|
||||
t.Fatalf("chtimes remote-newer.txt: %v", err)
|
||||
}
|
||||
|
||||
quickResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
"--quick",
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
quickResult.AssertExitCode(t, 0)
|
||||
quickResult.AssertStdoutStatus(t, true)
|
||||
|
||||
quickOut := quickResult.Stdout
|
||||
if got := gjson.Get(quickOut, "data.detection").String(); got != "quick" {
|
||||
t.Fatalf("quick detection=%q want quick\nstdout:\n%s", got, quickOut)
|
||||
}
|
||||
assertStatusBucketEntry(t, quickOut, "unchanged", "same-mtime.txt", tokSameMtime)
|
||||
assertStatusBucketEntry(t, quickOut, "modified", "remote-newer.txt", tokRemoteNewer)
|
||||
assertStatusBucketEntry(t, quickOut, "new_local", "local-only.txt", "")
|
||||
assertStatusBucketEntry(t, quickOut, "new_remote", "remote-only.txt", tokRemoteOnly)
|
||||
assertStatusBucketLen(t, quickOut, "unchanged", 1)
|
||||
assertStatusBucketLen(t, quickOut, "modified", 1)
|
||||
assertStatusBucketLen(t, quickOut, "new_local", 1)
|
||||
assertStatusBucketLen(t, quickOut, "new_remote", 1)
|
||||
|
||||
exactResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{
|
||||
"drive", "+status",
|
||||
"--local-dir", "local",
|
||||
"--folder-token", folderToken,
|
||||
},
|
||||
WorkDir: workDir,
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
exactResult.AssertExitCode(t, 0)
|
||||
exactResult.AssertStdoutStatus(t, true)
|
||||
|
||||
exactOut := exactResult.Stdout
|
||||
if got := gjson.Get(exactOut, "data.detection").String(); got != "exact" {
|
||||
t.Fatalf("exact detection=%q want exact\nstdout:\n%s", got, exactOut)
|
||||
}
|
||||
assertStatusBucketEntry(t, exactOut, "modified", "same-mtime.txt", tokSameMtime)
|
||||
assertStatusBucketEntry(t, exactOut, "unchanged", "remote-newer.txt", tokRemoteNewer)
|
||||
assertStatusBucketEntry(t, exactOut, "new_local", "local-only.txt", "")
|
||||
assertStatusBucketEntry(t, exactOut, "new_remote", "remote-only.txt", tokRemoteOnly)
|
||||
assertStatusBucketLen(t, exactOut, "unchanged", 1)
|
||||
assertStatusBucketLen(t, exactOut, "modified", 1)
|
||||
assertStatusBucketLen(t, exactOut, "new_local", 1)
|
||||
assertStatusBucketLen(t, exactOut, "new_remote", 1)
|
||||
}
|
||||
|
||||
type driveStatusListedFile struct {
|
||||
Token string
|
||||
ModifiedTime string
|
||||
}
|
||||
|
||||
func listDriveFolderFilesByName(t *testing.T, ctx context.Context, folderToken string) map[string]driveStatusListedFile {
|
||||
t.Helper()
|
||||
params := fmt.Sprintf(`{"folder_token":"%s","page_size":200}`, folderToken)
|
||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||
Args: []string{"drive", "files", "list", "--params", params},
|
||||
DefaultAs: "bot",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
result.AssertExitCode(t, 0)
|
||||
|
||||
files := make(map[string]driveStatusListedFile)
|
||||
gjson.Get(result.Stdout, "data.files").ForEach(func(_, entry gjson.Result) bool {
|
||||
name := entry.Get("name").String()
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
files[name] = driveStatusListedFile{
|
||||
Token: entry.Get("token").String(),
|
||||
ModifiedTime: entry.Get("modified_time").String(),
|
||||
}
|
||||
return true
|
||||
})
|
||||
return files
|
||||
}
|
||||
|
||||
func mustParseDriveEpochForE2E(t *testing.T, raw string) time.Time {
|
||||
t.Helper()
|
||||
v, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil {
|
||||
t.Fatalf("parse Drive epoch %q: %v", raw, err)
|
||||
}
|
||||
switch {
|
||||
case v > 1e14 || v < -1e14:
|
||||
return time.UnixMicro(v)
|
||||
case v > 1e11 || v < -1e11:
|
||||
return time.UnixMilli(v)
|
||||
default:
|
||||
return time.Unix(v, 0)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatusBucketEntry(t *testing.T, stdout, bucket, relPath, fileToken string) {
|
||||
t.Helper()
|
||||
entry := gjson.Get(stdout, `data.`+bucket+`.#(rel_path="`+relPath+`")`)
|
||||
if !entry.Exists() {
|
||||
t.Fatalf("bucket %s missing rel_path %q\nstdout:\n%s", bucket, relPath, stdout)
|
||||
}
|
||||
if fileToken == "" {
|
||||
if got := entry.Get("file_token").String(); got != "" {
|
||||
t.Fatalf("bucket %s rel_path %q unexpectedly carried file_token=%q\nstdout:\n%s", bucket, relPath, got, stdout)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got := entry.Get("file_token").String(); got != fileToken {
|
||||
t.Fatalf("bucket %s rel_path %q file_token=%q want %q\nstdout:\n%s", bucket, relPath, got, fileToken, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStatusBucketLen(t *testing.T, stdout, bucket string, want int) {
|
||||
t.Helper()
|
||||
if got := int(gjson.Get(stdout, "data."+bucket+".#").Int()); got != want {
|
||||
t.Fatalf("bucket %s length=%d want %d\nstdout:\n%s", bucket, got, want, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user