From cdd96f380e35c692958b1f90f1eb298b557df695 Mon Sep 17 00:00:00 2001 From: wangweiming Date: Thu, 30 Apr 2026 16:29:52 +0800 Subject: [PATCH 1/2] feat: add drive version shortcut Change-Id: I87bb32c86e3c3362f541ccc6320c656eb795ec9b --- internal/core/types.go | 4 +- 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 +++ 12 files changed, 1549 insertions(+), 2 deletions(-) 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/internal/core/types.go b/internal/core/types.go index cf842e6a4..5191948e0 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -42,8 +42,8 @@ func ResolveEndpoints(brand LarkBrand) Endpoints { } default: return Endpoints{ - Open: "https://open.feishu.cn", - Accounts: "https://accounts.feishu.cn", + Open: "https://open.feishu-pre.cn", + Accounts: "https://accounts.feishu-pre.cn", MCP: "https://mcp.feishu.cn", AppLink: "https://applink.feishu.cn", } 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 09b667844..77691ef47 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) | 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. | 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) +} From f69c3bafd84e1c37371e480c0729ea25736558b1 Mon Sep 17 00:00:00 2001 From: wangweiming Date: Thu, 14 May 2026 11:21:05 +0800 Subject: [PATCH 2/2] feat: add markdown +diff shortcut Change-Id: I475e0ba99b77f17535c0ac65622e38ddcf1ffd12 --- go.mod | 1 + go.sum | 15 +- shortcuts/markdown/helpers.go | 23 + shortcuts/markdown/markdown_diff.go | 519 ++++++++++++++++++ shortcuts/markdown/markdown_diff_test.go | 297 ++++++++++ shortcuts/markdown/markdown_test.go | 2 +- shortcuts/markdown/shortcuts.go | 1 + shortcuts/register_markdown_test.go | 1 + skills/lark-drive/SKILL.md | 1 + skills/lark-markdown/SKILL.md | 5 +- .../references/lark-markdown-diff.md | 156 ++++++ .../cli_e2e/markdown/markdown_dryrun_test.go | 56 ++ .../markdown/markdown_workflow_test.go | 52 ++ 13 files changed, 1126 insertions(+), 3 deletions(-) create mode 100644 shortcuts/markdown/markdown_diff.go create mode 100644 shortcuts/markdown/markdown_diff_test.go create mode 100644 skills/lark-markdown/references/lark-markdown-diff.md diff --git a/go.mod b/go.mod index 770cdf589..3eba1bfa6 100644 --- a/go.mod +++ b/go.mod @@ -10,6 +10,7 @@ require ( github.com/google/uuid v1.6.0 github.com/itchyny/gojq v0.12.17 github.com/larksuite/oapi-sdk-go/v3 v3.5.4 + github.com/sergi/go-diff v1.4.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/smartystreets/goconvey v1.8.1 github.com/spf13/cobra v1.10.2 diff --git a/go.sum b/go.sum index 451a3591d..a9336b8d3 100644 --- a/go.sum +++ b/go.sum @@ -43,6 +43,7 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -71,6 +72,11 @@ github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/larksuite/oapi-sdk-go/v3 v3.5.4 h1:U2S9x9LrfH++ZqJ+YAiUlqzCWJmVXhFdS8Z7rIBH8H0= github.com/larksuite/oapi-sdk-go/v3 v3.5.4/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= @@ -95,6 +101,8 @@ github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGByCY= @@ -105,8 +113,10 @@ github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -161,7 +171,10 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/shortcuts/markdown/helpers.go b/shortcuts/markdown/helpers.go index 1b83cf146..8697202f4 100644 --- a/shortcuts/markdown/helpers.go +++ b/shortcuts/markdown/helpers.go @@ -5,6 +5,7 @@ package markdown import ( "bytes" + "context" "errors" "fmt" "io" @@ -133,6 +134,28 @@ func markdownSourceSize(runtime *common.RuntimeContext, spec markdownUploadSpec) return size, nil } +func openMarkdownDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken string) (*http.Response, string, error) { + return openMarkdownDownloadVersion(ctx, runtime, fileToken, "") +} + +func openMarkdownDownloadVersion(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (*http.Response, string, error) { + req := &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)), + } + if strings.TrimSpace(version) != "" { + req.QueryParams = larkcore.QueryParams{ + "version": []string{strings.TrimSpace(version)}, + } + } + + resp, err := runtime.DoAPIStream(ctx, req) + if err != nil { + return nil, "", output.ErrNetwork("download failed: %s", err) + } + return resp, fileNameFromDownloadHeader(resp.Header, fileToken+".md"), nil +} + func markdownDryRunFileField(spec markdownUploadSpec) string { if spec.FilePath != "" { return "@" + spec.FilePath diff --git a/shortcuts/markdown/markdown_diff.go b/shortcuts/markdown/markdown_diff.go new file mode 100644 index 000000000..6367a242e --- /dev/null +++ b/shortcuts/markdown/markdown_diff.go @@ -0,0 +1,519 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "context" + "fmt" + "io" + "regexp" + "strings" + + "github.com/sergi/go-diff/diffmatchpatch" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const ( + markdownDiffModeRemoteVsRemote = "remote_vs_remote" + markdownDiffModeRemoteVsLocal = "remote_vs_local" +) + +var markdownDiffVersionRe = regexp.MustCompile(`^\d{1,19}$`) + +type markdownDiffSpec struct { + FileToken string + FromVersion string + ToVersion string + FilePath string + ContextLines int + Format string +} + +type markdownDiffHunk struct { + Header string `json:"header"` + OldStart int `json:"old_start"` + OldLines int `json:"old_lines"` + NewStart int `json:"new_start"` + NewLines int `json:"new_lines"` +} + +type markdownDiffLineKind int + +const ( + markdownDiffLineEqual markdownDiffLineKind = iota + markdownDiffLineDelete + markdownDiffLineInsert +) + +type markdownDiffLineOp struct { + Kind markdownDiffLineKind + Content string +} + +type markdownDiffHunkRange struct { + Start int + End int +} + +func validateMarkdownDiffSpec(runtime *common.RuntimeContext, spec markdownDiffSpec) error { + if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + if spec.FromVersion != "" { + if err := validateMarkdownDiffVersionValue(spec.FromVersion, "--from-version"); err != nil { + return err + } + } + if spec.ToVersion != "" { + if err := validateMarkdownDiffVersionValue(spec.ToVersion, "--to-version"); err != nil { + return err + } + } + if spec.FilePath != "" { + if _, err := validate.SafeInputPath(spec.FilePath); err != nil { + return output.ErrValidation("unsafe file path: %s", err) + } + if err := validateMarkdownFileName(spec.FilePath, "--file"); err != nil { + return err + } + } + if spec.ContextLines < 0 { + return output.ErrValidation("--context-lines must be >= 0") + } + if spec.Format != "" && spec.Format != "json" && spec.Format != "pretty" { + return output.ErrValidation("markdown +diff only supports --format json or pretty") + } + if spec.FilePath == "" { + if spec.FromVersion == "" && spec.ToVersion == "" { + return common.FlagErrorf("specify --from-version, or both --from-version and --to-version, or use --file for remote vs local diff") + } + if spec.FromVersion == "" && spec.ToVersion != "" { + return common.FlagErrorf("--to-version requires --from-version") + } + return nil + } + if spec.ToVersion != "" { + return common.FlagErrorf("--to-version is not supported together with --file") + } + return nil +} + +func validateMarkdownDiffVersionValue(value, flagName string) error { + value = strings.TrimSpace(value) + if value == "" { + return output.ErrValidation("%s cannot be empty", flagName) + } + if !markdownDiffVersionRe.MatchString(value) { + return output.ErrValidation("%s must be a numeric version string", flagName) + } + return nil +} + +func markdownDiffMode(spec markdownDiffSpec) string { + if spec.FilePath != "" { + return markdownDiffModeRemoteVsLocal + } + return markdownDiffModeRemoteVsRemote +} + +func markdownDiffDryRun(spec markdownDiffSpec) *common.DryRunAPI { + dry := common.NewDryRunAPI().Desc("Download the requested Markdown content, compute a unified diff locally, and print the result without modifying the remote file") + switch markdownDiffMode(spec) { + case markdownDiffModeRemoteVsLocal: + if spec.FromVersion != "" { + dry.GET("/open-apis/drive/v1/files/:file_token/download"). + Desc("[1] Download the specified remote Markdown version"). + Set("file_token", spec.FileToken). + Params(map[string]interface{}{"version": spec.FromVersion}) + } else { + dry.GET("/open-apis/drive/v1/files/:file_token/download"). + Desc("[1] Download the latest remote Markdown version"). + Set("file_token", spec.FileToken) + } + dry.Set("local_file", spec.FilePath) + dry.Set("mode", markdownDiffModeRemoteVsLocal) + default: + dry.GET("/open-apis/drive/v1/files/:file_token/download"). + Desc("[1] Download the base remote Markdown version"). + Set("file_token", spec.FileToken). + Params(map[string]interface{}{"version": spec.FromVersion}) + if spec.ToVersion != "" { + dry.GET("/open-apis/drive/v1/files/:file_token/download"). + Desc("[2] Download the target remote Markdown version"). + Set("file_token", spec.FileToken). + Params(map[string]interface{}{"version": spec.ToVersion}) + } else { + dry.GET("/open-apis/drive/v1/files/:file_token/download"). + Desc("[2] Download the latest remote Markdown version"). + Set("file_token", spec.FileToken) + } + dry.Set("mode", markdownDiffModeRemoteVsRemote) + } + dry.Set("context_lines", spec.ContextLines) + return dry +} + +func downloadMarkdownContent(ctx context.Context, runtime *common.RuntimeContext, fileToken, version string) (string, string, error) { + resp, fileName, err := openMarkdownDownloadVersion(ctx, runtime, fileToken, version) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + + payload, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", output.ErrNetwork("download failed: %s", err) + } + return fileName, string(payload), nil +} + +func readMarkdownLocalFile(runtime *common.RuntimeContext, filePath string) (string, error) { + f, err := runtime.FileIO().Open(filePath) + if err != nil { + return "", common.WrapInputStatError(err) + } + defer f.Close() + + payload, err := io.ReadAll(f) + if err != nil { + return "", output.ErrValidation("cannot read file: %s", err) + } + return string(payload), nil +} + +func splitMarkdownDiffLines(text string) []string { + if text == "" { + return nil + } + lines := strings.SplitAfter(text, "\n") + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +func markdownDiffLineOps(fromContent, toContent string) []markdownDiffLineOp { + dmp := diffmatchpatch.New() + dmp.DiffTimeout = 0 + before, after, lineArray := dmp.DiffLinesToRunes(fromContent, toContent) + diffs := dmp.DiffMainRunes(before, after, false) + // Keep the diff line-based. Running cleanup after hydrating real text + // would re-split replacements into word-level edits. + diffs = dmp.DiffCharsToLines(diffs, lineArray) + + ops := make([]markdownDiffLineOp, 0, len(diffs)) + for _, diff := range diffs { + lines := splitMarkdownDiffLines(diff.Text) + for _, line := range lines { + switch diff.Type { + case diffmatchpatch.DiffDelete: + ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineDelete, Content: line}) + case diffmatchpatch.DiffInsert: + ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineInsert, Content: line}) + default: + ops = append(ops, markdownDiffLineOp{Kind: markdownDiffLineEqual, Content: line}) + } + } + } + return ops +} + +func markdownDiffSummary(ops []markdownDiffLineOp) (bool, int, int) { + added := 0 + deleted := 0 + changed := false + for _, op := range ops { + switch op.Kind { + case markdownDiffLineDelete: + changed = true + deleted++ + case markdownDiffLineInsert: + changed = true + added++ + } + } + return changed, added, deleted +} + +func markdownDiffHunkRanges(ops []markdownDiffLineOp, contextLines int) []markdownDiffHunkRange { + if len(ops) == 0 { + return nil + } + changedLines := make([]int, 0) + for i, op := range ops { + if op.Kind != markdownDiffLineEqual { + changedLines = append(changedLines, i) + } + } + if len(changedLines) == 0 { + return nil + } + + ranges := make([]markdownDiffHunkRange, 0, len(changedLines)) + current := markdownDiffHunkRange{ + Start: max(0, changedLines[0]-contextLines), + End: min(len(ops), changedLines[0]+contextLines+1), + } + for _, idx := range changedLines[1:] { + next := markdownDiffHunkRange{ + Start: max(0, idx-contextLines), + End: min(len(ops), idx+contextLines+1), + } + if next.Start <= current.End { + if next.End > current.End { + current.End = next.End + } + continue + } + ranges = append(ranges, current) + current = next + } + ranges = append(ranges, current) + return ranges +} + +func markdownDiffHunkAt(ops []markdownDiffLineOp, r markdownDiffHunkRange) markdownDiffHunk { + oldBefore := 0 + newBefore := 0 + for _, op := range ops[:r.Start] { + if op.Kind != markdownDiffLineInsert { + oldBefore++ + } + if op.Kind != markdownDiffLineDelete { + newBefore++ + } + } + + oldLines := 0 + newLines := 0 + for _, op := range ops[r.Start:r.End] { + if op.Kind != markdownDiffLineInsert { + oldLines++ + } + if op.Kind != markdownDiffLineDelete { + newLines++ + } + } + + oldStart := oldBefore + 1 + newStart := newBefore + 1 + if oldLines == 0 { + oldStart = oldBefore + } + if newLines == 0 { + newStart = newBefore + } + + return markdownDiffHunk{ + Header: fmt.Sprintf("@@ -%d,%d +%d,%d @@", oldStart, oldLines, newStart, newLines), + OldStart: oldStart, + OldLines: oldLines, + NewStart: newStart, + NewLines: newLines, + } +} + +func buildMarkdownUnifiedDiff(fromLabel, toLabel string, ops []markdownDiffLineOp, ranges []markdownDiffHunkRange) string { + if len(ranges) == 0 { + return "" + } + + var b strings.Builder + fmt.Fprintf(&b, "--- %s\n", fromLabel) + fmt.Fprintf(&b, "+++ %s\n", toLabel) + for _, r := range ranges { + hunk := markdownDiffHunkAt(ops, r) + b.WriteString(hunk.Header) + b.WriteByte('\n') + for _, op := range ops[r.Start:r.End] { + prefix := ' ' + switch op.Kind { + case markdownDiffLineDelete: + prefix = '-' + case markdownDiffLineInsert: + prefix = '+' + } + b.WriteByte(byte(prefix)) + b.WriteString(op.Content) + if !strings.HasSuffix(op.Content, "\n") { + b.WriteByte('\n') + } + } + } + return b.String() +} + +func summarizeMarkdownDiff(fromLabel, toLabel, fromContent, toContent string, contextLines int) (string, bool, int, int, []markdownDiffHunk) { + ops := markdownDiffLineOps(fromContent, toContent) + changed, added, deleted := markdownDiffSummary(ops) + ranges := markdownDiffHunkRanges(ops, contextLines) + hunks := make([]markdownDiffHunk, 0, len(ranges)) + for _, r := range ranges { + hunks = append(hunks, markdownDiffHunkAt(ops, r)) + } + return buildMarkdownUnifiedDiff(fromLabel, toLabel, ops, ranges), changed, added, deleted, hunks +} + +func colorizeUnifiedDiff(diffText string) string { + if diffText == "" { + return "" + } + lines := strings.SplitAfter(diffText, "\n") + var b strings.Builder + for _, line := range lines { + trimmed := strings.TrimRight(line, "\n") + suffix := "" + if strings.HasSuffix(line, "\n") { + suffix = "\n" + } + switch { + case strings.HasPrefix(trimmed, "@@"): + b.WriteString(output.Cyan) + b.WriteString(trimmed) + b.WriteString(output.Reset) + case strings.HasPrefix(trimmed, "+++"), strings.HasPrefix(trimmed, "---"), strings.HasPrefix(trimmed, "diff --git"), strings.HasPrefix(trimmed, "index "): + b.WriteString(output.Bold) + b.WriteString(trimmed) + b.WriteString(output.Reset) + case strings.HasPrefix(trimmed, "+") && !strings.HasPrefix(trimmed, "+++"): + b.WriteString(output.Green) + b.WriteString(trimmed) + b.WriteString(output.Reset) + case strings.HasPrefix(trimmed, "-") && !strings.HasPrefix(trimmed, "---"): + b.WriteString(output.Red) + b.WriteString(trimmed) + b.WriteString(output.Reset) + default: + b.WriteString(trimmed) + } + b.WriteString(suffix) + } + return b.String() +} + +func prettyPrintMarkdownDiff(w io.Writer, data map[string]interface{}) { + if !common.GetBool(data, "changed") { + io.WriteString(w, "No differences.\n") + return + } + io.WriteString(w, colorizeUnifiedDiff(common.GetString(data, "diff"))) +} + +var MarkdownDiff = common.Shortcut{ + Service: "markdown", + Command: "+diff", + Description: "Compare remote Markdown versions or compare remote Markdown against a local file", + Risk: "read", + Scopes: []string{"drive:file:download"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "file-token", Desc: "target Markdown file token", Required: true}, + {Name: "from-version", Desc: "base remote version; when --to-version is omitted, compare this version to the latest remote version"}, + {Name: "to-version", Desc: "target remote version; requires --from-version"}, + {Name: "file", Desc: "local .md file path to compare against the remote content"}, + {Name: "context-lines", Desc: "number of unchanged context lines to include around each diff hunk", Type: "int", Default: "3"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateMarkdownDiffSpec(runtime, markdownDiffSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + FromVersion: strings.TrimSpace(runtime.Str("from-version")), + ToVersion: strings.TrimSpace(runtime.Str("to-version")), + FilePath: strings.TrimSpace(runtime.Str("file")), + ContextLines: runtime.Int("context-lines"), + Format: runtime.Format, + }) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + return markdownDiffDryRun(markdownDiffSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + FromVersion: strings.TrimSpace(runtime.Str("from-version")), + ToVersion: strings.TrimSpace(runtime.Str("to-version")), + FilePath: strings.TrimSpace(runtime.Str("file")), + ContextLines: runtime.Int("context-lines"), + }) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec := markdownDiffSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + FromVersion: strings.TrimSpace(runtime.Str("from-version")), + ToVersion: strings.TrimSpace(runtime.Str("to-version")), + FilePath: strings.TrimSpace(runtime.Str("file")), + ContextLines: runtime.Int("context-lines"), + } + + var ( + fromLabel string + toLabel string + fromContent string + toContent string + err error + ) + + switch markdownDiffMode(spec) { + case markdownDiffModeRemoteVsLocal: + fromLabel = "a/" + spec.FileToken + if spec.FromVersion != "" { + fromLabel += "@version:" + spec.FromVersion + } else { + fromLabel += "@latest" + } + _, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion) + if err != nil { + return err + } + + toLabel = "b/" + spec.FilePath + toContent, err = readMarkdownLocalFile(runtime, spec.FilePath) + if err != nil { + return err + } + default: + fromLabel = "a/" + spec.FileToken + "@version:" + spec.FromVersion + _, fromContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.FromVersion) + if err != nil { + return err + } + + if spec.ToVersion != "" { + toLabel = "b/" + spec.FileToken + "@version:" + spec.ToVersion + _, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, spec.ToVersion) + } else { + toLabel = "b/" + spec.FileToken + "@latest" + _, toContent, err = downloadMarkdownContent(ctx, runtime, spec.FileToken, "") + } + if err != nil { + return err + } + } + + diffText, changed, addedLines, deletedLines, hunks := summarizeMarkdownDiff(fromLabel, toLabel, fromContent, toContent, spec.ContextLines) + + out := map[string]interface{}{ + "changed": changed, + "mode": markdownDiffMode(spec), + "file_token": spec.FileToken, + "from_version": spec.FromVersion, + "to_version": spec.ToVersion, + "from_label": fromLabel, + "to_label": toLabel, + "added_lines": addedLines, + "deleted_lines": deletedLines, + "context_lines": spec.ContextLines, + "hunks": hunks, + "diff": diffText, + } + if spec.FilePath != "" { + out["local_file"] = spec.FilePath + } + + runtime.OutFormatRaw(out, nil, func(w io.Writer) { + prettyPrintMarkdownDiff(w, out) + }) + return nil + }, +} diff --git a/shortcuts/markdown/markdown_diff_test.go b/shortcuts/markdown/markdown_diff_test.go new file mode 100644 index 000000000..87d6b6aa3 --- /dev/null +++ b/shortcuts/markdown/markdown_diff_test.go @@ -0,0 +1,297 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package markdown + +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/internal/output" +) + +func TestMarkdownDiffRejectsUnsupportedFormat(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig()) + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--from-version", "7633658129540910621", + "--format", "table", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "only supports --format json or pretty") { + t.Fatalf("expected format validation error, got %v", err) + } +} + +func TestMarkdownDiffRejectsToVersionWithoutFromVersion(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig()) + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--to-version", "7633658129540910628", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--to-version requires --from-version") { + t.Fatalf("expected version validation error, got %v", err) + } +} + +func TestMarkdownDiffRemoteVsRemoteJSON(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("# Title\n\n- alpha\n- beta\n"), + Headers: http.Header{ + "Content-Disposition": []string{`attachment; filename="README.md"`}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628", + Status: 200, + RawBody: []byte("# Title\n\n- alpha\n- beta updated\n- gamma\n"), + Headers: http.Header{ + "Content-Disposition": []string{`attachment; filename="README.md"`}, + }, + }) + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--from-version", "7633658129540910621", + "--to-version", "7633658129540910628", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env struct { + OK bool `json:"ok"` + Data struct { + Changed bool `json:"changed"` + Mode string `json:"mode"` + FromVersion string `json:"from_version"` + ToVersion string `json:"to_version"` + AddedLines int `json:"added_lines"` + DeletedLines int `json:"deleted_lines"` + Diff string `json:"diff"` + Hunks []markdownDiffHunk `json:"hunks"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("json unmarshal error: %v\n%s", err, stdout.String()) + } + if !env.OK { + t.Fatalf("expected ok=true, got false: %s", stdout.String()) + } + if !env.Data.Changed { + t.Fatalf("expected changed=true: %s", stdout.String()) + } + if env.Data.Mode != markdownDiffModeRemoteVsRemote { + t.Fatalf("mode = %q, want %q", env.Data.Mode, markdownDiffModeRemoteVsRemote) + } + if env.Data.FromVersion != "7633658129540910621" || env.Data.ToVersion != "7633658129540910628" { + t.Fatalf("versions = %q -> %q", env.Data.FromVersion, env.Data.ToVersion) + } + if env.Data.AddedLines != 2 || env.Data.DeletedLines != 1 { + t.Fatalf("added/deleted = %d/%d, want 2/1", env.Data.AddedLines, env.Data.DeletedLines) + } + if len(env.Data.Hunks) != 1 { + t.Fatalf("len(hunks) = %d, want 1", len(env.Data.Hunks)) + } + if !strings.Contains(env.Data.Diff, "@@") || !strings.Contains(env.Data.Diff, "+- gamma") { + t.Fatalf("diff missing expected content: %s", env.Data.Diff) + } +} + +func TestMarkdownDiffRemoteVsLocalPretty(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download", + Status: 200, + RawBody: []byte("# Title\n\nhello old\n"), + Headers: http.Header{ + "Content-Disposition": []string{`attachment; filename="README.md"`}, + }, + }) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + if err := os.WriteFile("local.md", []byte("# Title\n\nhello new\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--file", "./local.md", + "--format", "pretty", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "@@") { + t.Fatalf("pretty output missing hunk header: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), output.Red+"-hello old"+output.Reset) { + t.Fatalf("pretty output missing removed line color: %q", stdout.String()) + } + if !strings.Contains(stdout.String(), output.Green+"+hello new"+output.Reset) { + t.Fatalf("pretty output missing added line color: %q", stdout.String()) + } +} + +func TestMarkdownDiffOmitsNoNewlineMarker(t *testing.T) { + diffText, changed, added, deleted, hunks := summarizeMarkdownDiff( + "a/test.md", + "b/test.md", + "# Title\n\nhello old", + "# Title\n\nhello new", + 3, + ) + if !changed { + t.Fatalf("expected changed=true") + } + if added != 1 || deleted != 1 { + t.Fatalf("added/deleted = %d/%d, want 1/1", added, deleted) + } + if len(hunks) != 1 { + t.Fatalf("len(hunks) = %d, want 1", len(hunks)) + } + if strings.Contains(diffText, "\\ No newline at end of file") { + t.Fatalf("diff should not contain no-newline marker: %q", diffText) + } + if !strings.Contains(diffText, "-hello old\n+hello new\n") { + t.Fatalf("diff missing expected newline-normalized replacement: %q", diffText) + } +} + +func TestMarkdownDiffRemoteVsRemoteJSONMultipleHunks(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("line1\nline2\nline3\nline4\nline5\nline6\n"), + Headers: http.Header{ + "Content-Disposition": []string{`attachment; filename="README.md"`}, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910628", + Status: 200, + RawBody: []byte("line1\nline2 changed\nline3\nline4\nline5 changed\nline6\n"), + Headers: http.Header{ + "Content-Disposition": []string{`attachment; filename="README.md"`}, + }, + }) + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--from-version", "7633658129540910621", + "--to-version", "7633658129540910628", + "--context-lines", "0", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var env struct { + OK bool `json:"ok"` + Data struct { + Changed bool `json:"changed"` + AddedLines int `json:"added_lines"` + DeletedLines int `json:"deleted_lines"` + Hunks []markdownDiffHunk `json:"hunks"` + Diff string `json:"diff"` + } `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &env); err != nil { + t.Fatalf("json unmarshal error: %v\n%s", err, stdout.String()) + } + if !env.OK || !env.Data.Changed { + t.Fatalf("expected changed=true: %s", stdout.String()) + } + if env.Data.AddedLines != 2 || env.Data.DeletedLines != 2 { + t.Fatalf("added/deleted = %d/%d, want 2/2", env.Data.AddedLines, env.Data.DeletedLines) + } + if len(env.Data.Hunks) != 2 { + t.Fatalf("len(hunks) = %d, want 2", len(env.Data.Hunks)) + } + if !strings.Contains(env.Data.Diff, "-line2") || !strings.Contains(env.Data.Diff, "+line5 changed") { + t.Fatalf("diff missing expected content: %s", env.Data.Diff) + } +} + +func TestMarkdownDiffNoChangesPretty(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download?version=7633658129540910621", + Status: 200, + RawBody: []byte("# Title\n"), + }) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_diff/download", + Status: 200, + RawBody: []byte("# Title\n"), + }) + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--from-version", "7633658129540910621", + "--format", "pretty", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := strings.TrimSpace(stdout.String()); got != "No differences." { + t.Fatalf("pretty no-change output = %q, want %q", got, "No differences.") + } +} + +func TestMarkdownDiffDryRunRemoteVsLocal(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig()) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + localPath := filepath.Join(".", "local.md") + if err := os.WriteFile(localPath, []byte("# local\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunMarkdown(t, MarkdownDiff, []string{ + "+diff", + "--file-token", "box_md_diff", + "--file", localPath, + "--dry-run", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/:file_token/download") && !strings.Contains(stdout.String(), "/open-apis/drive/v1/files/box_md_diff/download") { + t.Fatalf("dry-run missing download call: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `"local_file": "local.md"`) && !strings.Contains(stdout.String(), `"local_file": "./local.md"`) { + t.Fatalf("dry-run missing local file metadata: %s", stdout.String()) + } +} diff --git a/shortcuts/markdown/markdown_test.go b/shortcuts/markdown/markdown_test.go index 193ac9897..221c60091 100644 --- a/shortcuts/markdown/markdown_test.go +++ b/shortcuts/markdown/markdown_test.go @@ -182,7 +182,7 @@ func TestShortcutsIncludesExpectedCommands(t *testing.T) { t.Parallel() got := Shortcuts() - want := []string{"+create", "+fetch", "+overwrite"} + want := []string{"+create", "+diff", "+fetch", "+overwrite"} if len(got) != len(want) { t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want)) diff --git a/shortcuts/markdown/shortcuts.go b/shortcuts/markdown/shortcuts.go index 5bc2d02ad..aa3e93deb 100644 --- a/shortcuts/markdown/shortcuts.go +++ b/shortcuts/markdown/shortcuts.go @@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common" func Shortcuts() []common.Shortcut { return []common.Shortcut{ MarkdownCreate, + MarkdownDiff, MarkdownFetch, MarkdownOverwrite, } diff --git a/shortcuts/register_markdown_test.go b/shortcuts/register_markdown_test.go index 7a622204e..57fa55c3c 100644 --- a/shortcuts/register_markdown_test.go +++ b/shortcuts/register_markdown_test.go @@ -16,6 +16,7 @@ func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) { for _, path := range [][]string{ {"markdown", "+create"}, + {"markdown", "+diff"}, {"markdown", "+fetch"}, {"markdown", "+overwrite"}, } { diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index 77691ef47..858aab4c8 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)。 +- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`。 - 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `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`。 diff --git a/skills/lark-markdown/SKILL.md b/skills/lark-markdown/SKILL.md index f91115747..65922ebb3 100644 --- a/skills/lark-markdown/SKILL.md +++ b/skills/lark-markdown/SKILL.md @@ -1,7 +1,7 @@ --- name: lark-markdown version: 1.0.0 -description: "飞书 Markdown:查看、创建、上传和编辑 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取或修改时使用。" +description: "飞书 Markdown:查看、创建、上传、编辑和比较 Markdown 文件。当用户需要创建或编辑 Markdown 文件、读取、修改或比较差异时使用。" metadata: requires: bins: ["lark-cli"] @@ -15,8 +15,10 @@ metadata: ## 快速决策 - 用户要**上传、创建一个原生 `.md` 文件**,使用 `lark-cli markdown +create` +- 用户要**比较原生 `.md` 文件的历史版本差异**,或比较远端 Markdown 与本地草稿,使用 `lark-cli markdown +diff` - 用户要**读取 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +fetch` - 用户要**覆盖更新 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +overwrite` +- 用户要先拿 Markdown 文件的历史版本号,再做比较/下载/回滚,先用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +version-history` - 用户要把本地 Markdown **导入成在线新版文档(docx)**,不要用本 skill,改用 [`lark-drive`](../lark-drive/SKILL.md) 的 `lark-cli drive +import --type docx` - 用户要对 Markdown 文件做**rename / move / delete / 搜索 / 权限 / 评论**等云空间操作,不要留在本 skill,切到 [`lark-drive`](../lark-drive/SKILL.md) @@ -37,6 +39,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli markdown + [flags]` | Shortcut | 说明 | |----------|------| | [`+create`](references/lark-markdown-create.md) | Create a Markdown file in Drive | +| [`+diff`](references/lark-markdown-diff.md) | Compare two remote Markdown versions, or compare remote Markdown against a local file | | [`+fetch`](references/lark-markdown-fetch.md) | Fetch a Markdown file from Drive | | [`+overwrite`](references/lark-markdown-overwrite.md) | Overwrite an existing Markdown file in Drive | diff --git a/skills/lark-markdown/references/lark-markdown-diff.md b/skills/lark-markdown/references/lark-markdown-diff.md new file mode 100644 index 000000000..5f18b79ae --- /dev/null +++ b/skills/lark-markdown/references/lark-markdown-diff.md @@ -0,0 +1,156 @@ +# markdown +diff + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +比较 Drive 中原生 Markdown 的两个历史版本,或比较远端 Markdown 与本地 `.md` 草稿。需要历史版本号时,先用 [`drive +version-history`](../../lark-drive/references/lark-drive-version-history.md) 获取 `version`,不要使用 `tag`。 + +## 命令 + +```bash +# 比较两个远端版本 +lark-cli markdown +diff \ + --file-token boxcnxxxx \ + --from-version 7633658129540910621 \ + --to-version 7633658129540910628 + +# 比较历史版本与远端最新版本 +lark-cli markdown +diff \ + --file-token boxcnxxxx \ + --from-version 7633658129540910621 + +# 比较远端最新版本与本地草稿 +lark-cli markdown +diff \ + --file-token boxcnxxxx \ + --file ./draft.md \ + --format pretty + +# 比较指定远端版本与本地草稿 +lark-cli markdown +diff \ + --file-token boxcnxxxx \ + --from-version 7633658129540910621 \ + --file ./draft.md + +# 预览底层请求 +lark-cli markdown +diff \ + --file-token boxcnxxxx \ + --from-version 7633658129540910621 \ + --to-version 7633658129540910628 \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标 Markdown 文件 token | +| `--from-version` | 否 | 基准远端版本;不传 `--file` 时必填,传 `--file` 时省略表示“远端最新 vs 本地文件” | +| `--to-version` | 否 | 目标远端版本;要求同时传 `--from-version`,且不能与 `--file` 一起使用。省略时表示远端最新版本 | +| `--file` | 否 | 本地 `.md` 文件路径;传入后进入“远端 vs 本地”比较模式 | +| `--context-lines` | 否 | unified diff 每个 hunk 前后保留的上下文行数,默认 `3` | +| `--format` | 否 | 仅支持 `json`(默认)和 `pretty` | + +## 关键行为 + +- `--file` 存在时: + - 省略 `--from-version` = 比较“远端最新版本 vs 本地文件” + - 传入 `--from-version` = 比较“指定远端版本 vs 本地文件” +- `--to-version` 只能用于“远端版本 vs 远端版本”,不能与 `--file` 同时出现 +- `--format pretty` 输出带颜色的 unified diff;`--format json` 返回结构化摘要和完整 diff 文本 +- 无差异时: + - `json` 输出里 `changed=false` + - `pretty` 输出固定为 `No differences.` + +## 返回值 + +```json +{ + "ok": true, + "identity": "user", + "data": { + "changed": true, + "mode": "remote_vs_remote", + "file_token": "boxcnxxxx", + "from_version": "7633658129540910621", + "to_version": "7633658129540910628", + "from_label": "a/boxcnxxxx@version:7633658129540910621", + "to_label": "b/boxcnxxxx@version:7633658129540910628", + "added_lines": 3, + "deleted_lines": 2, + "context_lines": 3, + "hunks": [ + { + "header": "@@ -1,6 +1,7 @@", + "old_start": 1, + "old_lines": 6, + "new_start": 1, + "new_lines": 7 + } + ], + "diff": "--- a/boxcnxxxx@version:7633658129540910621\n+++ b/boxcnxxxx@version:7633658129540910628\n@@ -1,2 +1,2 @@\n..." + } +} +``` + +完整字段说明: + +| 字段 | 层级 | 含义 | +|------|------|------| +| `ok` | 顶层 | CLI 通用成功标记;`true` 表示命令执行成功 | +| `identity` | 顶层 | 本次执行使用的身份,通常是 `user` 或 `bot` | +| `data` | 顶层 | 本次 diff 的业务结果对象 | +| `changed` | `data` | 是否存在差异;`true` 表示两侧内容不同,`false` 表示完全一致 | +| `mode` | `data` | 比较模式;`remote_vs_remote` = 远端对远端,`remote_vs_local` = 远端对本地 | +| `file_token` | `data` | 被比较的远端 Markdown 文件 token | +| `from_version` | `data` | 基准远端版本号;远端最新 vs 本地时可能为空字符串 | +| `to_version` | `data` | 目标远端版本号;当目标侧是远端最新版本或本地文件时通常为空字符串 | +| `from_label` | `data` | unified diff 基准侧标签名,会直接出现在 `diff` 文本的 `---` 头部 | +| `to_label` | `data` | unified diff 目标侧标签名,会直接出现在 `diff` 文本的 `+++` 头部 | +| `added_lines` | `data` | 新增行数统计 | +| `deleted_lines` | `data` | 删除行数统计 | +| `context_lines` | `data` | 每个 hunk 前后保留的上下文行数,对应传入的 `--context-lines` | +| `hunks` | `data` | 结构化的变更块摘要数组;每个元素对应 patch 里的一个 `@@ ... @@` 段 | +| `diff` | `data` | 完整 unified diff 文本;最适合直接阅读或保存 | +| `local_file` | `data` | 仅在 `remote_vs_local` 模式下出现;值就是传给 `--file` 的本地 Markdown 路径 | + +标签字段补充: + +- `from_label` / `to_label` 只用于标识 diff 两侧,不代表额外 API 字段 +- `from_label` 表示基准侧,`to_label` 表示目标侧 +- 远端版本通常形如 `a/@version:`、`b/@version:` +- 当目标侧是远端最新版本时,`to_label` 形如 `b/@latest` +- 当目标侧是本地文件时,`to_label` 形如 `b/./draft.md` + +`hunks` 子字段说明: + +| 字段 | 含义 | +|------|------| +| `header` | 原始 hunk 头,例如 `@@ -3,1 +3,1 @@` | +| `old_start` | 旧内容从第几行开始 | +| `old_lines` | 旧内容这段覆盖多少行 | +| `new_start` | 新内容从第几行开始 | +| `new_lines` | 新内容这段覆盖多少行 | + +补充说明: + +- `hunks` 适合 agent 或脚本快速定位变更范围;完整逐行内容仍以 `diff` 字段为准 +- `changed=false` 时,`hunks` 通常为空数组,`diff` 通常为空字符串;如果使用 `--format pretty`,终端输出会是 `No differences.` + +远端 vs 本地时会额外返回: + +```json +{ + "local_file": "./draft.md" +} +``` + +- `local_file` + - 只有传了 `--file`、进入“远端 vs 本地”模式时才会返回 + - 值就是本次命令实际比较的本地 Markdown 路径,也就是你传给 `--file` 的那个路径 + - 它表示“目标侧本地文件”,不是临时下载文件,也不是远端文件名 + - 如果没有这个字段,说明本次是“远端版本 vs 远端版本” + +## 参考 + +- [lark-markdown](../SKILL.md) — Markdown 域总览 +- [lark-drive-version-history](../../lark-drive/references/lark-drive-version-history.md) — 获取可用于 diff 的历史版本号 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/tests/cli_e2e/markdown/markdown_dryrun_test.go b/tests/cli_e2e/markdown/markdown_dryrun_test.go index b957de34e..d905cb77e 100644 --- a/tests/cli_e2e/markdown/markdown_dryrun_test.go +++ b/tests/cli_e2e/markdown/markdown_dryrun_test.go @@ -96,6 +96,62 @@ func TestMarkdownCreateDryRun_RejectsEmptyContent(t *testing.T) { assert.Contains(t, errMsg, "empty markdown content is not supported") } +func TestMarkdownDiffDryRun_RemoteVsRemote(t *testing.T) { + setMarkdownDryRunConfigEnv(t) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+diff", + "--file-token", "boxcnMarkdownDryRun", + "--from-version", "7633658129540910621", + "--to-version", "7633658129540910628", + "--context-lines", "1", + "--dry-run", + }, + DefaultAs: "bot", + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download") + assert.Contains(t, output, `"mode": "remote_vs_remote"`) + assert.Contains(t, output, `"version": "7633658129540910621"`) + assert.Contains(t, output, `"version": "7633658129540910628"`) + assert.Contains(t, output, `"context_lines": 1`) +} + +func TestMarkdownDiffDryRun_RemoteVsLocal(t *testing.T) { + setMarkdownDryRunConfigEnv(t) + + dir := t.TempDir() + require.NoError(t, os.WriteFile(dir+"/draft.md", []byte("# draft\n"), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+diff", + "--file-token", "boxcnMarkdownDryRun", + "--file", "./draft.md", + "--dry-run", + }, + DefaultAs: "bot", + WorkDir: dir, + }) + require.NoError(t, err) + result.AssertExitCode(t, 0) + + output := strings.TrimSpace(result.Stdout) + assert.Contains(t, output, "/open-apis/drive/v1/files/boxcnMarkdownDryRun/download") + assert.Contains(t, output, `"mode": "remote_vs_local"`) + assert.Contains(t, output, `"local_file": "./draft.md"`) +} + func TestMarkdownFetchDryRun_OutputFile(t *testing.T) { setMarkdownDryRunConfigEnv(t) diff --git a/tests/cli_e2e/markdown/markdown_workflow_test.go b/tests/cli_e2e/markdown/markdown_workflow_test.go index 149a85f0e..4fef7cc30 100644 --- a/tests/cli_e2e/markdown/markdown_workflow_test.go +++ b/tests/cli_e2e/markdown/markdown_workflow_test.go @@ -6,10 +6,12 @@ package markdown import ( "context" "os" + "strings" "testing" "time" clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/tidwall/gjson" ) @@ -97,4 +99,54 @@ func TestMarkdownLifecycleWorkflow(t *testing.T) { fetchUpdatedResult.AssertExitCode(t, 0) fetchUpdatedResult.AssertStdoutStatus(t, true) require.Equal(t, updatedContent, gjson.Get(fetchUpdatedResult.Stdout, "data.content").String(), "stdout:\n%s", fetchUpdatedResult.Stdout) + + historyResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "drive", "+version-history", + "--file-token", fileToken, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + historyResult.AssertExitCode(t, 0) + historyResult.AssertStdoutStatus(t, true) + + latestVersion := gjson.Get(overwriteResult.Stdout, "data.version").String() + require.NotEmpty(t, latestVersion, "stdout:\n%s", overwriteResult.Stdout) + + versions := gjson.Get(historyResult.Stdout, "data.versions").Array() + require.GreaterOrEqual(t, len(versions), 2, "stdout:\n%s", historyResult.Stdout) + + var previousVersion string + for _, version := range versions { + candidate := version.Get("version").String() + if candidate != "" && candidate != latestVersion { + previousVersion = candidate + break + } + } + require.NotEmpty(t, previousVersion, "stdout:\n%s", historyResult.Stdout) + + diffResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+diff", + "--file-token", fileToken, + "--from-version", previousVersion, + "--to-version", latestVersion, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + diffResult.AssertExitCode(t, 0) + diffResult.AssertStdoutStatus(t, true) + + assert.True(t, gjson.Get(diffResult.Stdout, "data.changed").Bool(), "stdout:\n%s", diffResult.Stdout) + assert.Equal(t, "remote_vs_remote", gjson.Get(diffResult.Stdout, "data.mode").String(), "stdout:\n%s", diffResult.Stdout) + assert.Equal(t, previousVersion, gjson.Get(diffResult.Stdout, "data.from_version").String(), "stdout:\n%s", diffResult.Stdout) + assert.Equal(t, latestVersion, gjson.Get(diffResult.Stdout, "data.to_version").String(), "stdout:\n%s", diffResult.Stdout) + assert.GreaterOrEqual(t, len(gjson.Get(diffResult.Stdout, "data.hunks").Array()), 1, "stdout:\n%s", diffResult.Stdout) + + diffText := gjson.Get(diffResult.Stdout, "data.diff").String() + assert.True(t, strings.Contains(diffText, "-hello markdown workflow") || strings.Contains(diffText, "-# Initial"), "stdout:\n%s", diffResult.Stdout) + assert.True(t, strings.Contains(diffText, "+new body") || strings.Contains(diffText, "+# Updated"), "stdout:\n%s", diffResult.Stdout) }