From e17a53abde5085ad4bdbc68d450a9ca9af15ae4c Mon Sep 17 00:00:00 2001 From: wangweiming Date: Thu, 30 Apr 2026 16:29:52 +0800 Subject: [PATCH] feat: add drive version shortcut Change-Id: I87bb32c86e3c3362f541ccc6320c656eb795ec9b --- shortcuts/drive/drive_version.go | 486 +++++++++++++++++ shortcuts/drive/drive_version_test.go | 504 ++++++++++++++++++ shortcuts/drive/shortcuts.go | 4 + shortcuts/drive/shortcuts_test.go | 4 + skills/lark-drive/SKILL.md | 5 + .../references/lark-drive-version-delete.md | 35 ++ .../references/lark-drive-version-get.md | 86 +++ .../references/lark-drive-version-history.md | 73 +++ .../references/lark-drive-version-revert.md | 35 ++ .../drive/drive_version_dryrun_test.go | 229 ++++++++ .../drive/drive_version_workflow_test.go | 86 +++ 11 files changed, 1547 insertions(+) create mode 100644 shortcuts/drive/drive_version.go create mode 100644 shortcuts/drive/drive_version_test.go create mode 100644 skills/lark-drive/references/lark-drive-version-delete.md create mode 100644 skills/lark-drive/references/lark-drive-version-get.md create mode 100644 skills/lark-drive/references/lark-drive-version-history.md create mode 100644 skills/lark-drive/references/lark-drive-version-revert.md create mode 100644 tests/cli_e2e/drive/drive_version_dryrun_test.go create mode 100644 tests/cli_e2e/drive/drive_version_workflow_test.go diff --git a/shortcuts/drive/drive_version.go b/shortcuts/drive/drive_version.go new file mode 100644 index 000000000..316d37839 --- /dev/null +++ b/shortcuts/drive/drive_version.go @@ -0,0 +1,486 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "io" + "math" + "net/http" + "path" + "path/filepath" + "regexp" + "strconv" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/util" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var driveVersionNumberRe = regexp.MustCompile(`^\d{1,19}$`) + +type driveVersionHistorySpec struct { + FileToken string + Limit int + Cursor string +} + +func validateDriveNumericValue(value, flagName, valueLabel string) error { + value = strings.TrimSpace(value) + if value == "" { + return output.ErrValidation("%s cannot be empty", flagName) + } + if !driveVersionNumberRe.MatchString(value) { + return output.ErrValidation("%s must be a numeric %s", flagName, valueLabel) + } + return nil +} + +func validateDriveVersionValue(value, flagName string) error { + return validateDriveNumericValue(value, flagName, "version string") +} + +func validateDriveCursorValue(value, flagName string) error { + return validateDriveNumericValue(value, flagName, "pagination cursor") +} + +func validateDriveVersionHistorySpec(spec driveVersionHistorySpec) error { + if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + if spec.Limit < 1 || spec.Limit > 200 { + return output.ErrValidation("invalid --limit %d: must be between 1 and 200", spec.Limit) + } + if spec.Cursor != "" { + if err := validateDriveCursorValue(spec.Cursor, "--cursor"); err != nil { + return err + } + } + return nil +} + +func driveVersionHistoryParams(spec driveVersionHistorySpec) map[string]interface{} { + params := map[string]interface{}{ + "only_tag": true, + "page_size": spec.Limit, + } + if spec.Cursor != "" { + params["last_edit_time"] = spec.Cursor + } + return params +} + +func driveVersionActionTypeLabel(raw int) string { + switch raw { + case 1: + return "upload" + case 2: + return "rename" + case 3: + return "delete_version" + case 4: + return "revert" + default: + return fmt.Sprintf("type_%d", raw) + } +} + +func driveVersionFieldString(m map[string]interface{}, key string) string { + if m == nil { + return "" + } + if s := common.GetString(m, key); s != "" { + return s + } + f, ok := util.ToFloat64(m[key]) + if !ok || math.IsInf(f, 0) || math.IsNaN(f) { + return "" + } + if math.Trunc(f) == f { + return strconv.FormatInt(int64(f), 10) + } + return strconv.FormatFloat(f, 'f', -1, 64) +} + +func transformDriveVersionHistory(items []interface{}) []map[string]interface{} { + versions := make([]map[string]interface{}, 0, len(items)) + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + version := common.GetString(m, "version") + if version == "" { + continue + } + versions = append(versions, map[string]interface{}{ + "version": version, + "name": common.GetString(m, "name"), + "edited_at": driveVersionFieldString(m, "edit_time"), + "edited_by": common.GetString(m, "edit_user_id"), + "size_bytes": int64(common.GetFloat(m, "size")), + "action_type": driveVersionActionTypeLabel(int(common.GetFloat(m, "type"))), + "is_deleted": common.GetBool(m, "is_deleted"), + "tag": int(common.GetFloat(m, "tag")), + }) + } + return versions +} + +func nextDriveVersionCursor(items []interface{}, hasMore bool) string { + if !hasMore || len(items) == 0 { + return "" + } + last, _ := items[len(items)-1].(map[string]interface{}) + return driveVersionFieldString(last, "edit_time") +} + +var DriveVersionHistory = common.Shortcut{ + Service: "drive", + Command: "+version-history", + Description: "List the version history of a Drive file", + Risk: "read", + Scopes: []string{"drive:file:download"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "file-token", Desc: "target file token", Required: true}, + {Name: "limit", Desc: "max versions to return (1-200)", Type: "int", Default: "20"}, + {Name: "cursor", Desc: "pagination cursor from the previous page's next_cursor"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateDriveVersionHistorySpec(driveVersionHistorySpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Limit: runtime.Int("limit"), + Cursor: strings.TrimSpace(runtime.Str("cursor")), + }) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec := driveVersionHistorySpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Limit: runtime.Int("limit"), + Cursor: strings.TrimSpace(runtime.Str("cursor")), + } + return common.NewDryRunAPI(). + Desc("Query version history with only_tag=true and optional pagination cursor"). + GET("/open-apis/drive/v1/files/:file_token/history"). + Set("file_token", spec.FileToken). + Params(driveVersionHistoryParams(spec)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec := driveVersionHistorySpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Limit: runtime.Int("limit"), + Cursor: strings.TrimSpace(runtime.Str("cursor")), + } + + data, err := runtime.CallAPI( + http.MethodGet, + fmt.Sprintf("/open-apis/drive/v1/files/%s/history", validate.EncodePathSegment(spec.FileToken)), + driveVersionHistoryParams(spec), + nil, + ) + if err != nil { + return err + } + + items := common.GetSlice(data, "items") + hasMore := common.GetBool(data, "has_more") + out := map[string]interface{}{ + "versions": transformDriveVersionHistory(items), + "has_more": hasMore, + } + if nextCursor := nextDriveVersionCursor(items, hasMore); nextCursor != "" { + out["next_cursor"] = nextCursor + } + + runtime.OutFormat(out, nil, nil) + return nil + }, +} + +type driveVersionGetSpec struct { + FileToken string + Version string + Output string + Overwrite bool +} + +func validateDriveVersionGetSpec(runtime *common.RuntimeContext, spec driveVersionGetSpec) error { + if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + if err := validateDriveVersionValue(spec.Version, "--version"); err != nil { + return err + } + if spec.Output == "" { + return nil + } + if _, err := validate.SafeOutputPath(spec.Output); err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + return nil +} + +func driveVersionGetOutputIsDirectory(runtime *common.RuntimeContext, outputPath string) bool { + if strings.HasSuffix(outputPath, "/") || strings.HasSuffix(outputPath, "\\") { + return true + } + info, err := runtime.FileIO().Stat(outputPath) + return err == nil && info.IsDir() +} + +func driveVersionFileNameFromDownloadHeader(header http.Header, fallback string) string { + name := fallback + if header != nil { + if headerName := larkcore.FileNameByHeader(header); strings.TrimSpace(headerName) != "" { + name = headerName + } + } + name = strings.ReplaceAll(strings.TrimSpace(name), "\\", "/") + name = path.Base(name) + if name == "" || name == "." || name == ".." { + return fallback + } + return name +} + +func prettyPrintDriveVersionSavedFile(w io.Writer, data map[string]interface{}) { + fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token")) + fmt.Fprintf(w, "version: %s\n", common.GetString(data, "version")) + fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name")) + fmt.Fprintf(w, "saved_path: %s\n", common.GetString(data, "saved_path")) + fmt.Fprintf(w, "size_bytes: %d\n", int64(common.GetFloat(data, "size_bytes"))) +} + +func prettyPrintDriveVersionContent(w io.Writer, data map[string]interface{}) { + fmt.Fprint(w, common.GetString(data, "content")) +} + +var DriveVersionGet = common.Shortcut{ + Service: "drive", + Command: "+version-get", + Description: "Download a specific version of a Drive file", + Risk: "read", + Scopes: []string{"drive:file:download"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "file-token", Desc: "target file token", Required: true}, + {Name: "version", Desc: "version from drive +version-history (not tag)", Required: true}, + {Name: "output", Desc: "local save path; omit to use the same default behavior as drive +download"}, + {Name: "overwrite", Type: "bool", Desc: "overwrite existing output file"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateDriveVersionGetSpec(runtime, driveVersionGetSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + Output: strings.TrimSpace(runtime.Str("output")), + Overwrite: runtime.Bool("overwrite"), + }) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec := driveVersionGetSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + Output: strings.TrimSpace(runtime.Str("output")), + } + outputPath := spec.Output + if outputPath == "" { + outputPath = "" + } + return common.NewDryRunAPI(). + Desc("Download a specific file version; when --output is omitted the CLI returns content directly"). + GET("/open-apis/drive/v1/files/:file_token/download"). + Set("file_token", spec.FileToken). + Set("output", outputPath). + Params(map[string]interface{}{"version": spec.Version}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec := driveVersionGetSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + Output: strings.TrimSpace(runtime.Str("output")), + Overwrite: runtime.Bool("overwrite"), + } + + resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(spec.FileToken)), + QueryParams: larkcore.QueryParams{ + "version": []string{spec.Version}, + }, + }) + if err != nil { + return output.ErrNetwork("download failed: %s", err) + } + defer resp.Body.Close() + + fileName := driveVersionFileNameFromDownloadHeader(resp.Header, spec.FileToken) + if spec.Output == "" { + payload, err := io.ReadAll(resp.Body) + if err != nil { + return output.ErrNetwork("download failed: %s", err) + } + out := map[string]interface{}{ + "file_token": spec.FileToken, + "version": spec.Version, + "file_name": fileName, + "content": string(payload), + "size_bytes": len(payload), + } + runtime.OutFormatRaw(out, nil, func(w io.Writer) { + prettyPrintDriveVersionContent(w, out) + }) + return nil + } + + outputPath := spec.Output + if driveVersionGetOutputIsDirectory(runtime, outputPath) { + outputPath = filepath.Join(outputPath, fileName) + } + if _, resolveErr := runtime.ResolveSavePath(outputPath); resolveErr != nil { + return output.ErrValidation("unsafe output path: %s", resolveErr) + } + if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !spec.Overwrite { + return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", outputPath) + } + + result, err := runtime.FileIO().Save(outputPath, fileio.SaveOptions{ + ContentType: resp.Header.Get("Content-Type"), + ContentLength: resp.ContentLength, + }, resp.Body) + if err != nil { + return common.WrapSaveErrorByCategory(err, "io") + } + + savedPath, _ := runtime.ResolveSavePath(outputPath) + if savedPath == "" { + savedPath = outputPath + } + out := map[string]interface{}{ + "file_token": spec.FileToken, + "version": spec.Version, + "file_name": fileName, + "saved_path": savedPath, + "size_bytes": result.Size(), + } + runtime.OutFormat(out, nil, func(w io.Writer) { + prettyPrintDriveVersionSavedFile(w, out) + }) + return nil + }, +} + +type driveVersionMutationSpec struct { + FileToken string + Version string +} + +func validateDriveVersionMutationSpec(spec driveVersionMutationSpec) error { + if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + return validateDriveVersionValue(spec.Version, "--version") +} + +var DriveVersionRevert = common.Shortcut{ + Service: "drive", + Command: "+version-revert", + Description: "Revert a Drive file to a specific historical version", + Risk: "write", + Scopes: []string{"drive:file:upload"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "file-token", Desc: "target file token", Required: true}, + {Name: "version", Desc: "version from drive +version-history to revert to (not tag)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateDriveVersionMutationSpec(driveVersionMutationSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + }) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec := driveVersionMutationSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + } + return common.NewDryRunAPI(). + Desc("Revert the current file to a specified historical version"). + POST("/open-apis/drive/v1/files/:file_token/revert"). + Set("file_token", spec.FileToken). + Body(map[string]interface{}{"version": spec.Version}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec := driveVersionMutationSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + } + if _, err := runtime.CallAPI( + http.MethodPost, + fmt.Sprintf("/open-apis/drive/v1/files/%s/revert", validate.EncodePathSegment(spec.FileToken)), + nil, + map[string]interface{}{"version": spec.Version}, + ); err != nil { + return err + } + + runtime.Out(map[string]interface{}{}, nil) + return nil + }, +} + +var DriveVersionDelete = common.Shortcut{ + Service: "drive", + Command: "+version-delete", + Description: "Delete a specific historical version of a Drive file", + Risk: "write", + Scopes: []string{"drive:file:upload"}, + AuthTypes: []string{"user", "bot"}, + Flags: []common.Flag{ + {Name: "file-token", Desc: "target file token", Required: true}, + {Name: "version", Desc: "version from drive +version-history to delete (not tag)", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateDriveVersionMutationSpec(driveVersionMutationSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + }) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec := driveVersionMutationSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + } + return common.NewDryRunAPI(). + Desc("Permanently delete a historical file version"). + POST("/open-apis/drive/v1/files/:file_token/version_del"). + Set("file_token", spec.FileToken). + Body(map[string]interface{}{"version": spec.Version}) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec := driveVersionMutationSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + Version: strings.TrimSpace(runtime.Str("version")), + } + if _, err := runtime.CallAPI( + http.MethodPost, + fmt.Sprintf("/open-apis/drive/v1/files/%s/version_del", validate.EncodePathSegment(spec.FileToken)), + nil, + map[string]interface{}{"version": spec.Version}, + ); err != nil { + return err + } + + runtime.Out(map[string]interface{}{}, nil) + return nil + }, +} diff --git a/shortcuts/drive/drive_version_test.go b/shortcuts/drive/drive_version_test.go new file mode 100644 index 000000000..1c1ff9634 --- /dev/null +++ b/shortcuts/drive/drive_version_test.go @@ -0,0 +1,504 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "encoding/json" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func TestValidateDriveVersionHistorySpec(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + spec driveVersionHistorySpec + wantErr string + }{ + { + name: "ok", + spec: driveVersionHistorySpec{FileToken: "box123", Limit: 20, Cursor: "1777013761763"}, + }, + { + name: "bad limit", + spec: driveVersionHistorySpec{FileToken: "box123", Limit: 0}, + wantErr: "invalid --limit", + }, + { + name: "bad cursor", + spec: driveVersionHistorySpec{FileToken: "box123", Limit: 20, Cursor: "abc"}, + wantErr: "--cursor must be a numeric pagination cursor", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + err := validateDriveVersionHistorySpec(tt.spec) + if tt.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) + } + }) + } +} + +func TestDriveVersionHistoryExecuteTransformsResponse(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_hist/history", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "items": []map[string]interface{}{ + { + "version": "7633658129540910621", + "name": "report.md", + "edit_time": 1777013761763, + "edit_user_id": "ou_hist_1", + "size": "12345", + "type": 1, + "is_deleted": false, + "tag": 7, + }, + { + "version": "7633658129540910622", + "name": "report.md", + "edit_time": 1777013770000, + "edit_user_id": "ou_hist_2", + "size": "12346", + "type": 4, + "is_deleted": true, + "tag": 8, + }, + }, + "has_more": true, + }, + }, + }) + + err := mountAndRunDrive(t, DriveVersionHistory, []string{ + "+version-history", + "--file-token", "box_hist", + "--limit", "2", + "--cursor", "1777013000000", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var envelope struct { + Data map[string]interface{} `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("unmarshal stdout: %v", err) + } + + if got := common.GetBool(envelope.Data, "has_more"); !got { + t.Fatalf("has_more = %v, want true", got) + } + if got := common.GetString(envelope.Data, "next_cursor"); got != "1777013770000" { + t.Fatalf("next_cursor = %q, want %q", got, "1777013770000") + } + + versions, _ := envelope.Data["versions"].([]interface{}) + if len(versions) != 2 { + t.Fatalf("len(versions) = %d, want 2", len(versions)) + } + first, _ := versions[0].(map[string]interface{}) + if got := common.GetString(first, "version"); got != "7633658129540910621" { + t.Fatalf("first.version = %q", got) + } + if got := common.GetString(first, "edited_at"); got != "1777013761763" { + t.Fatalf("first.edited_at = %q, want %q", got, "1777013761763") + } + if got := common.GetString(first, "action_type"); got != "upload" { + t.Fatalf("first.action_type = %q, want upload", got) + } + if got := common.GetBool(first, "is_deleted"); got { + t.Fatalf("first.is_deleted = %v, want false", got) + } + second, _ := versions[1].(map[string]interface{}) + if got := common.GetString(second, "action_type"); got != "revert" { + t.Fatalf("second.action_type = %q, want revert", got) + } + if got := common.GetBool(second, "is_deleted"); !got { + t.Fatalf("second.is_deleted = %v, want true", got) + } +} + +func TestDriveVersionGetWritesSpecificVersion(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_ver/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("versioned-data"), + Headers: http.Header{ + "Content-Type": []string{"application/octet-stream"}, + "Content-Disposition": []string{`attachment; filename="report-v7.md"`}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + + err := mountAndRunDrive(t, DriveVersionGet, []string{ + "+version-get", + "--file-token", "box_ver", + "--version", "7633658129540910621", + "--output", "version.bin", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "version.bin")) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "versioned-data" { + t.Fatalf("downloaded content = %q", string(data)) + } + if !strings.Contains(stdout.String(), `"version": "7633658129540910621"`) { + t.Fatalf("stdout missing version: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `"saved_path":`) { + t.Fatalf("stdout missing saved_path: %s", stdout.String()) + } +} + +func TestDriveVersionGetReturnsContentWhenOutputIsOmitted(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_ver/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("# hello\n"), + Headers: http.Header{ + "Content-Type": []string{"text/plain; charset=utf-8"}, + "Content-Disposition": []string{`attachment; filename="report-v7.md"`}, + }, + }) + + err := mountAndRunDrive(t, DriveVersionGet, []string{ + "+version-get", + "--file-token", "box_ver", + "--version", "7633658129540910621", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.Contains(stdout.String(), `"file_name": "report-v7.md"`) { + t.Fatalf("stdout missing file_name: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `"content": "# hello\n"`) { + t.Fatalf("stdout missing content: %s", stdout.String()) + } +} + +func TestDriveVersionGetRejectsExistingFileWithoutOverwrite(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_ver/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("versioned-data"), + Headers: http.Header{ + "Content-Type": []string{"application/octet-stream"}, + "Content-Disposition": []string{`attachment; filename="report-v7.md"`}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.WriteFile("version.bin", []byte("existing"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveVersionGet, []string{ + "+version-get", + "--file-token", "box_ver", + "--version", "7633658129540910621", + "--output", "version.bin", + "--as", "bot", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "output file already exists") { + t.Fatalf("expected output exists error, got %v", err) + } +} + +func TestDriveVersionGetOverwritesExistingFileWhenRequested(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_ver/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("versioned-data"), + Headers: http.Header{ + "Content-Type": []string{"application/octet-stream"}, + "Content-Disposition": []string{`attachment; filename="report-v7.md"`}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.WriteFile("version.bin", []byte("existing"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunDrive(t, DriveVersionGet, []string{ + "+version-get", + "--file-token", "box_ver", + "--version", "7633658129540910621", + "--output", "version.bin", + "--overwrite", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join(tmpDir, "version.bin")) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "versioned-data" { + t.Fatalf("downloaded content = %q", string(data)) + } +} + +func TestDriveVersionGetSavesUsingRemoteNameWhenOutputIsExistingDirectory(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_ver/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("versioned-data"), + Headers: http.Header{ + "Content-Type": []string{"application/octet-stream"}, + "Content-Disposition": []string{`attachment; filename="report-v7.md"`}, + }, + }) + + tmpDir := t.TempDir() + withDriveWorkingDir(t, tmpDir) + if err := os.MkdirAll("downloads", 0o755); err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + + err := mountAndRunDrive(t, DriveVersionGet, []string{ + "+version-get", + "--file-token", "box_ver", + "--version", "7633658129540910621", + "--output", "downloads", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join("downloads", "report-v7.md")) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "versioned-data" { + t.Fatalf("downloaded content = %q", string(data)) + } +} + +func TestDriveVersionRevertPostsVersionAndReturnsEmptyData(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + revertStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/box_rev/revert", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + } + reg.Register(revertStub) + + err := mountAndRunDrive(t, DriveVersionRevert, []string{ + "+version-revert", + "--file-token", "box_rev", + "--version", "7633658129540910621", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeCapturedJSONBody(t, revertStub) + if got := common.GetString(body, "version"); got != "7633658129540910621" { + t.Fatalf("body.version = %q, want 7633658129540910621", got) + } + if !strings.Contains(stdout.String(), `"data": {}`) { + t.Fatalf("stdout = %s, want empty data object", stdout.String()) + } +} + +func TestDriveVersionDeletePostsVersionAndReturnsEmptyData(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) + deleteStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/box_del/version_del", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{}, + }, + } + reg.Register(deleteStub) + + err := mountAndRunDrive(t, DriveVersionDelete, []string{ + "+version-delete", + "--file-token", "box_del", + "--version", "7633658129540910621", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeCapturedJSONBody(t, deleteStub) + if got := common.GetString(body, "version"); got != "7633658129540910621" { + t.Fatalf("body.version = %q, want 7633658129540910621", got) + } + if !strings.Contains(stdout.String(), `"data": {}`) { + t.Fatalf("stdout = %s, want empty data object", stdout.String()) + } +} + +func TestDriveVersionShortcutsDoNotAcceptYes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + shortcut common.Shortcut + args []string + }{ + { + name: "revert", + shortcut: DriveVersionRevert, + args: []string{ + "+version-revert", + "--file-token", "box_rev", + "--version", "7633658129540910621", + "--yes", + "--as", "bot", + }, + }, + { + name: "delete", + shortcut: DriveVersionDelete, + args: []string{ + "+version-delete", + "--file-token", "box_del", + "--version", "7633658129540910621", + "--yes", + "--as", "bot", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + err := mountAndRunDrive(t, tt.shortcut, tt.args, f, nil) + if err == nil { + t.Fatal("expected unknown flag error, got nil") + } + if !strings.Contains(err.Error(), "unknown flag: --yes") { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} + +func TestDriveVersionShortcutsSupportUserDryRun(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + shortcut common.Shortcut + args []string + }{ + { + name: "history", + shortcut: DriveVersionHistory, + args: []string{ + "+version-history", + "--file-token", "box_hist", + "--limit", "2", + "--cursor", "1777013000000", + "--as", "user", + "--dry-run", + }, + }, + { + name: "get", + shortcut: DriveVersionGet, + args: []string{ + "+version-get", + "--file-token", "box_get", + "--version", "7633658129540910621", + "--output", "version.bin", + "--as", "user", + "--dry-run", + }, + }, + { + name: "revert", + shortcut: DriveVersionRevert, + args: []string{ + "+version-revert", + "--file-token", "box_rev", + "--version", "7633658129540910621", + "--as", "user", + "--dry-run", + }, + }, + { + name: "delete", + shortcut: DriveVersionDelete, + args: []string{ + "+version-delete", + "--file-token", "box_del", + "--version", "7633658129540910621", + "--as", "user", + "--dry-run", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig()) + + err := mountAndRunDrive(t, tt.shortcut, tt.args, f, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index fcd3d805e..7edcbfd1d 100644 --- a/shortcuts/drive/shortcuts.go +++ b/shortcuts/drive/shortcuts.go @@ -16,6 +16,10 @@ func Shortcuts() []common.Shortcut { DriveExport, DriveExportDownload, DriveImport, + DriveVersionHistory, + DriveVersionGet, + DriveVersionRevert, + DriveVersionDelete, DriveMove, DriveDelete, DriveStatus, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 3116c0c5a..2d7a6911e 100644 --- a/shortcuts/drive/shortcuts_test.go +++ b/shortcuts/drive/shortcuts_test.go @@ -15,6 +15,10 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { "+create-folder", "+create-shortcut", "+download", + "+version-history", + "+version-get", + "+version-revert", + "+version-delete", "+add-comment", "+export", "+export-download", diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 1a171d5f1..23f93eb01 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -20,6 +20,7 @@ metadata: - 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。 - 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。 - 用户要在 Drive 里上传、创建、读取、覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。 +- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。 - 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。 - 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。 - 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token `;不要误切到 `wiki` 域命令。 @@ -236,6 +237,10 @@ Shortcut 是对常用操作的高级封装(`lark-cli drive + [flags]`) | [`+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 | | [`+export-download`](references/lark-drive-export-download.md) | Download an exported file by file_token | | [`+import`](references/lark-drive-import.md) | Import a local file to Drive as a cloud document (docx, sheet, bitable) | +| [`+version-history`](references/lark-drive-version-history.md) | List historical versions of a file with only_tag=true and cursor-based pagination | +| [`+version-get`](references/lark-drive-version-get.md) | Download a specific historical version of a file | +| [`+version-revert`](references/lark-drive-version-revert.md) | Revert a file to a specific historical version | +| [`+version-delete`](references/lark-drive-version-delete.md) | Delete a specific historical version of a file | | [`+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) | 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. | diff --git a/skills/lark-drive/references/lark-drive-version-delete.md b/skills/lark-drive/references/lark-drive-version-delete.md new file mode 100644 index 000000000..261007e3a --- /dev/null +++ b/skills/lark-drive/references/lark-drive-version-delete.md @@ -0,0 +1,35 @@ +# drive +version-delete + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +删除指定的历史版本。该 shortcut 同时支持 `--as user` 和 `--as bot`;自动化场景推荐使用 `--as bot`。 + +## 命令 + +```bash +lark-cli drive +version-delete \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --as bot + +lark-cli drive +version-delete \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --as user +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标文件 token | +| `--version` | 是 | `drive +version-history` 返回的长数字 `version` 字段,不是 `tag` | + +## 返回值 + +无额外业务字段,以命令成功 / 失败为准。 + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-drive/references/lark-drive-version-get.md b/skills/lark-drive/references/lark-drive-version-get.md new file mode 100644 index 000000000..ffd0d03a4 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-version-get.md @@ -0,0 +1,86 @@ +# drive +version-get + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +下载指定版本的文件内容。该 shortcut 同时支持 `--as user` 和 `--as bot`;自动化场景推荐使用 `--as bot`。 + +## 命令 + +```bash +lark-cli drive +version-get \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --as bot + +lark-cli drive +version-get \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --as user + +lark-cli drive +version-get \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --output ./downloads/ \ + --as bot + +lark-cli drive +version-get \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --output ./artifact.bin \ + --overwrite \ + --as bot +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标文件 token | +| `--version` | 是 | `drive +version-history` 返回的长数字 `version` 字段,不是 `tag` | +| `--output` | 否 | 本地保存路径或目录;省略时直接在 stdout 返回下载内容 | +| `--overwrite` | 否 | 覆盖已存在的本地输出文件 | + +## 关键行为 + +- 省略 `--output` 时,CLI 不落盘,直接返回 `content` +- `--output` 指向已存在目录,或以 `/` / `\\` 结尾时,CLI 会使用远端文件名保存 +- 目标文件已存在时,只有显式传 `--overwrite` 才会覆盖 + +## 返回值 + +省略 `--output` 时: + +```json +{ + "ok": true, + "identity": "bot", + "data": { + "file_token": "boxcnxxxxxxxx", + "version": "7633658129540910621", + "file_name": "artifact.bin", + "content": "file bytes decoded as UTF-8 text", + "size_bytes": 12345 + } +} +``` + +指定 `--output` 时: + +```json +{ + "ok": true, + "identity": "bot", + "data": { + "file_token": "boxcnxxxxxxxx", + "version": "7633658129540910621", + "file_name": "artifact.bin", + "saved_path": "/abs/path/artifact.bin", + "size_bytes": 12345 + } +} +``` + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-drive/references/lark-drive-version-history.md b/skills/lark-drive/references/lark-drive-version-history.md new file mode 100644 index 000000000..e1a229776 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-version-history.md @@ -0,0 +1,73 @@ +# drive +version-history + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +列出指定文件的历史版本快照。该 shortcut 同时支持 `--as user` 和 `--as bot`;自动化场景推荐使用 `--as bot`。 + +## 命令 + +```bash +lark-cli drive +version-history \ + --file-token boxcnxxxxxxxx \ + --as bot + +lark-cli drive +version-history \ + --file-token boxcnxxxxxxxx \ + --as user + +lark-cli drive +version-history \ + --file-token boxcnxxxxxxxx \ + --limit 50 \ + --cursor 1777013761763 \ + --as bot + +lark-cli drive +version-history \ + --file-token boxcnxxxxxxxx \ + --dry-run \ + --as bot +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标文件 token | +| `--limit` | 否 | 返回条数上限,范围 `1-200`,默认 `20` | +| `--cursor` | 否 | 分页游标;取上一页返回的 `next_cursor` 回填 | + +## 关键行为 + +- shortcut 内部固定传 `only_tag=true` +- 返回 `has_more=true` 时,使用 `next_cursor` 继续翻页 +- `versions[].version` 是传给 `drive +version-get` / `+version-revert` / `+version-delete` 的长数字版本串;`tag` 只是展示序号,不能替代 `version` +- `versions[].is_deleted` 为布尔值,表示该历史版本是否已被删除 + +## 返回值 + +```json +{ + "ok": true, + "identity": "bot", + "data": { + "versions": [ + { + "version": "7633658129540910621", + "name": "report.md", + "edited_at": "1777013761763", + "edited_by": "ou_xxx", + "size_bytes": "12345", + "action_type": "upload", + "is_deleted": false, + "tag": 7 + } + ], + "has_more": true, + "next_cursor": "1777013761763" + } +} +``` + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-drive/references/lark-drive-version-revert.md b/skills/lark-drive/references/lark-drive-version-revert.md new file mode 100644 index 000000000..31a9e3078 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-version-revert.md @@ -0,0 +1,35 @@ +# drive +version-revert + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +将文件回滚到指定历史版本。该 shortcut 同时支持 `--as user` 和 `--as bot`;自动化场景推荐使用 `--as bot`。 + +## 命令 + +```bash +lark-cli drive +version-revert \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --as bot + +lark-cli drive +version-revert \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --as user +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标文件 token | +| `--version` | 是 | `drive +version-history` 返回的长数字 `version` 字段,不是 `tag` | + +## 返回值 + +无额外业务字段,以命令成功 / 失败为准。 + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/tests/cli_e2e/drive/drive_version_dryrun_test.go b/tests/cli_e2e/drive/drive_version_dryrun_test.go new file mode 100644 index 000000000..6cb174c76 --- /dev/null +++ b/tests/cli_e2e/drive/drive_version_dryrun_test.go @@ -0,0 +1,229 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "strings" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDriveVersionHistoryDryRun(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-history", + "--file-token", "boxcnHistoryDryRun", + "--limit", "5", + "--cursor", "1777013761763", + "--dry-run", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnHistoryDryRun/history") + assert.Contains(t, output, `"only_tag": true`) + assert.Contains(t, output, `"page_size": 5`) + assert.Contains(t, output, `"last_edit_time": "1777013761763"`) +} + +func TestDriveVersionGetDryRun(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-get", + "--file-token", "boxcnVersionDryRun", + "--version", "7633658129540910621", + "--output", "./artifact.bin", + "--dry-run", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/download") + assert.Contains(t, output, `"version": "7633658129540910621"`) + assert.Contains(t, output, `"output": "./artifact.bin"`) +} + +func TestDriveVersionGetDryRunWithoutOutputUsesStdout(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-get", + "--file-token", "boxcnVersionDryRun", + "--version", "7633658129540910621", + "--dry-run", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/download") + assert.Contains(t, output, `"version": "7633658129540910621"`) + assert.Contains(t, output, `"output": "\u003cstdout\u003e"`) +} + +func TestDriveVersionRevertDryRun(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-revert", + "--file-token", "boxcnVersionDryRun", + "--version", "7633658129540910621", + "--dry-run", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/revert") + assert.Contains(t, output, `"version": "7633658129540910621"`) +} + +func TestDriveVersionDeleteDryRun(t *testing.T) { + setDriveDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-delete", + "--file-token", "boxcnVersionDryRun", + "--version", "7633658129540910621", + "--dry-run", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnVersionDryRun/version_del") + assert.Contains(t, output, `"version": "7633658129540910621"`) +} + +func TestDriveVersionDryRunSupportsUser(t *testing.T) { + clie2e.SkipWithoutUserToken(t) + setDriveDryRunConfigEnv(t) + + tests := []struct { + name string + args []string + wantContains []string + }{ + { + name: "history", + args: []string{ + "drive", "+version-history", + "--file-token", "boxcnHistoryDryRunUser", + "--limit", "5", + "--cursor", "1777013761763", + "--dry-run", + }, + wantContains: []string{ + "/open-apis/drive/v1/files/boxcnHistoryDryRunUser/history", + `"only_tag": true`, + `"page_size": 5`, + }, + }, + { + name: "get", + args: []string{ + "drive", "+version-get", + "--file-token", "boxcnVersionDryRunUser", + "--version", "7633658129540910621", + "--output", "./artifact-user.bin", + "--dry-run", + }, + wantContains: []string{ + "/open-apis/drive/v1/files/boxcnVersionDryRunUser/download", + `"version": "7633658129540910621"`, + `"output": "./artifact-user.bin"`, + }, + }, + { + name: "revert", + args: []string{ + "drive", "+version-revert", + "--file-token", "boxcnVersionDryRunUser", + "--version", "7633658129540910621", + "--dry-run", + }, + wantContains: []string{ + "/open-apis/drive/v1/files/boxcnVersionDryRunUser/revert", + `"version": "7633658129540910621"`, + }, + }, + { + name: "delete", + args: []string{ + "drive", "+version-delete", + "--file-token", "boxcnVersionDryRunUser", + "--version", "7633658129540910621", + "--dry-run", + }, + wantContains: []string{ + "/open-apis/drive/v1/files/boxcnVersionDryRunUser/version_del", + `"version": "7633658129540910621"`, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: tt.args, + DefaultAs: "user", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + for _, needle := range tt.wantContains { + assert.Contains(t, output, needle) + } + }) + } +} diff --git a/tests/cli_e2e/drive/drive_version_workflow_test.go b/tests/cli_e2e/drive/drive_version_workflow_test.go new file mode 100644 index 000000000..e53b8a78c --- /dev/null +++ b/tests/cli_e2e/drive/drive_version_workflow_test.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "os" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestDriveVersionWorkflow(t *testing.T) { + if os.Getenv("LARK_DRIVE_VERSION_E2E") == "" { + t.Skip("set LARK_DRIVE_VERSION_E2E=1 to run drive version live workflow") + } + + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + fileName := "lark-cli-version-workflow-" + suffix + ".md" + + createResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+create", + "--name", fileName, + "--content", "# v1\n", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + createResult.AssertExitCode(t, 0) + createResult.AssertStdoutStatus(t, true) + + fileToken := gjson.Get(createResult.Stdout, "data.file_token").String() + require.NotEmpty(t, fileToken, "stdout:\n%s", createResult.Stdout) + + parentT.Cleanup(func() { + cleanupCtx, cleanupCancel := clie2e.CleanupContext() + defer cleanupCancel() + + deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{ + Args: []string{ + "drive", "+delete", + "--file-token", fileToken, + "--type", "file", + "--yes", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + clie2e.ReportCleanupFailure(parentT, "delete version workflow file "+fileToken, deleteResult, deleteErr) + }) + + overwriteResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+overwrite", + "--file-token", fileToken, + "--content", "# v2\n", + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + overwriteResult.AssertExitCode(t, 0) + overwriteResult.AssertStdoutStatus(t, true) + + historyResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-history", + "--file-token", fileToken, + }, + DefaultAs: "bot", + BinaryPath: "../../../lark-cli", + }) + require.NoError(t, err) + historyResult.AssertExitCode(t, 0) + historyResult.AssertStdoutStatus(t, true) +}