diff --git a/README.md b/README.md index 5f978fad2..2f1f200b9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [中文版](./README.zh.md) | [English](./README.md) -The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 23 AI Agent [Skills](./skills/). +The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, Markdown, and more, with 200+ commands and 23 AI Agent [Skills](./skills/). [Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing) @@ -28,6 +28,7 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t | 💬 Messenger | Send/reply messages, create and manage group chats, view chat history & threads, search messages, download media | | 📄 Docs | Create, read, update, and search documents, read/write media & whiteboards | | 📁 Drive | Upload and download files, search docs & wiki, manage comments | +| 📝 Markdown | Create, fetch, and overwrite Drive-native `.md` files | | 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics | | 📈 Sheets | Create, read, write, append, find, and export spreadsheet data | | 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides | @@ -139,6 +140,7 @@ lark-cli auth status | `lark-im` | Send/reply messages, group chat management, message search, upload/download images & files, reactions | | `lark-doc` | Create, read, update, search documents (Markdown-based) | | `lark-drive` | Upload, download files, manage permissions & comments | +| `lark-markdown` | Create, fetch, and overwrite Drive-native Markdown files | | `lark-sheets` | Create, read, write, append, find, export spreadsheets | | `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides | | `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics | diff --git a/README.zh.md b/README.zh.md index e45a2395c..a859c62d1 100644 --- a/README.zh.md +++ b/README.zh.md @@ -6,7 +6,7 @@ [中文版](./README.zh.md) | [English](./README.md) -飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 23 个 AI Agent [Skills](./skills/)。 +飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议、Markdown 等核心业务域,提供 200+ 命令及 23 个 AI Agent [Skills](./skills/)。 [安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献) @@ -28,6 +28,7 @@ | 💬 即时通讯 | 发送/回复消息、创建和管理群聊、查看聊天记录与话题、搜索消息、下载媒体文件 | | 📄 云文档 | 创建、读取、更新文档、搜索文档、读写素材与画板 | | 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 | +| 📝 Markdown | 创建、读取、覆盖更新 Drive 中的原生 `.md` 文件 | | 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 | | 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 | | 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 | @@ -140,6 +141,7 @@ lark-cli auth status | `lark-im` | 发送/回复消息、群聊管理、消息搜索、上传下载图片与文件、表情回复 | | `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown) | | `lark-drive` | 上传、下载文件,管理权限与评论 | +| `lark-markdown` | 创建、读取、覆盖更新 Drive 中的原生 Markdown 文件 | | `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 | | `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 | | `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 | diff --git a/internal/core/types.go b/internal/core/types.go index bae8613ae..569bcebd9 100644 --- a/internal/core/types.go +++ b/internal/core/types.go @@ -40,8 +40,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", } } diff --git a/shortcuts/drive/drive_version.go b/shortcuts/drive/drive_version.go new file mode 100644 index 000000000..1d0ab0325 --- /dev/null +++ b/shortcuts/drive/drive_version.go @@ -0,0 +1,403 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package drive + +import ( + "context" + "fmt" + "net/http" + "path/filepath" + "regexp" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var driveVersionNumberRe = regexp.MustCompile(`^\d{1,19}$`) + +type driveVersionHistorySpec struct { + FileToken string + Limit int + Cursor string +} + +func validateDriveVersionValue(value, flagName 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 version string", flagName) + } + return nil +} + +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 := validateDriveVersionValue(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 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": common.GetString(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"))), + "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 common.GetString(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{"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) + } + if _, statErr := runtime.FileIO().Stat(spec.Output); statErr == nil && !spec.Overwrite { + return output.ErrValidation("output file already exists: %s (use --overwrite to replace)", spec.Output) + } + return nil +} + +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{"bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "file-token", Desc: "target file token", Required: true}, + {Name: "version", Desc: "target version number", 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 = spec.FileToken + } + return common.NewDryRunAPI(). + Desc("Download a specific file version to local storage"). + 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"), + } + + outputPath := spec.Output + if outputPath == "" { + outputPath = spec.FileToken + } + + 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) + } + + 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() + + 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": filepath.Base(savedPath), + "saved_path": savedPath, + "size_bytes": result.Size(), + } + runtime.OutFormat(out, nil, nil) + 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{"bot"}, + Flags: []common.Flag{ + {Name: "file-token", Desc: "target file token", Required: true}, + {Name: "version", Desc: "version number to revert to", 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{"bot"}, + Flags: []common.Flag{ + {Name: "file-token", Desc: "target file token", Required: true}, + {Name: "version", Desc: "version number to delete", 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..935579b87 --- /dev/null +++ b/shortcuts/drive/drive_version_test.go @@ -0,0 +1,284 @@ +// 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 version string", + }, + } + + 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, + "tag": 7, + }, + { + "version": "7633658129540910622", + "name": "report.md", + "edit_time": "1777013770000", + "edit_user_id": "ou_hist_2", + "size": "12346", + "type": 4, + "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, "action_type"); got != "upload" { + t.Fatalf("first.action_type = %q, want upload", 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) + } +} + +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 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) + } + }) + } +} diff --git a/shortcuts/drive/shortcuts.go b/shortcuts/drive/shortcuts.go index bf4680ce9..e29a2f4e2 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, DriveTaskResult, diff --git a/shortcuts/drive/shortcuts_test.go b/shortcuts/drive/shortcuts_test.go index 61c357699..f059ff762 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/shortcuts/markdown/helpers.go b/shortcuts/markdown/helpers.go new file mode 100644 index 000000000..ac7cd0123 --- /dev/null +++ b/shortcuts/markdown/helpers.go @@ -0,0 +1,512 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "path" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +const markdownSinglePartSizeLimit = common.MaxDriveMediaUploadSinglePartSize + +type markdownUploadSpec struct { + FileToken string + FileName string + FolderToken string + FilePath string + Content string + ContentSet bool + FileSet bool +} + +type markdownUploadResult struct { + FileToken string + Version string +} + +type markdownMultipartSession struct { + UploadID string + BlockSize int64 + BlockNum int +} + +func validateMarkdownSpec(runtime *common.RuntimeContext, spec markdownUploadSpec, requireName bool) error { + switch { + case spec.ContentSet && spec.FileSet: + return common.FlagErrorf("--content and --file are mutually exclusive") + case !spec.ContentSet && !spec.FileSet: + return common.FlagErrorf("specify exactly one of --content or --file") + } + + if runtime.Changed("folder-token") && strings.TrimSpace(spec.FolderToken) == "" { + return common.FlagErrorf("--folder-token cannot be empty; omit it to upload into Drive root folder") + } + if spec.FolderToken != "" { + if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil { + return output.ErrValidation("%s", err) + } + } + + if requireName && spec.ContentSet { + if strings.TrimSpace(spec.FileName) == "" { + return common.FlagErrorf("--name is required when using --content") + } + if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil { + return err + } + } + + if spec.FileSet { + if strings.TrimSpace(spec.FilePath) == "" { + return common.FlagErrorf("--file cannot be empty") + } + if _, err := validate.SafeInputPath(spec.FilePath); err != nil { + return output.ErrValidation("unsafe file path: %s", err) + } + if err := validateMarkdownFileName(filepath.Base(spec.FilePath), "--file"); err != nil { + return err + } + } + + if spec.FileName != "" { + if err := validateMarkdownFileName(spec.FileName, "--name"); err != nil { + return err + } + } + + return nil +} + +func validateMarkdownFileName(name, flagName string) error { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return common.FlagErrorf("%s cannot be empty", flagName) + } + if !strings.HasSuffix(strings.ToLower(trimmed), ".md") { + return common.FlagErrorf("%s must end with .md", flagName) + } + return nil +} + +func finalMarkdownFileName(spec markdownUploadSpec) string { + if strings.TrimSpace(spec.FileName) != "" { + return strings.TrimSpace(spec.FileName) + } + if strings.TrimSpace(spec.FilePath) == "" { + return "" + } + return filepath.Base(spec.FilePath) +} + +func markdownSourceBytes(runtime *common.RuntimeContext, spec markdownUploadSpec) ([]byte, error) { + if spec.ContentSet { + return []byte(spec.Content), nil + } + if strings.TrimSpace(spec.FilePath) == "" { + return nil, common.FlagErrorf("--file cannot be empty") + } + + f, err := runtime.FileIO().Open(spec.FilePath) + if err != nil { + return nil, common.WrapInputStatError(err) + } + defer f.Close() + + data, err := io.ReadAll(f) + if err != nil { + return nil, output.ErrValidation("cannot read file: %s", err) + } + return data, nil +} + +func markdownDryRunFileField(spec markdownUploadSpec) string { + if spec.FilePath != "" { + return "@" + spec.FilePath + } + return "" +} + +func markdownUploadDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI { + fileName := finalMarkdownFileName(spec) + + if !multipart { + body := map[string]interface{}{ + "file_name": fileName, + "parent_type": "explorer", + "parent_node": spec.FolderToken, + "size": fileSize, + "file": markdownDryRunFileField(spec), + } + if spec.FileToken != "" { + body["file_token"] = spec.FileToken + } + + desc := "multipart/form-data upload" + if spec.FileToken != "" { + desc = "multipart/form-data overwrite upload" + } + + return common.NewDryRunAPI(). + Desc(desc). + POST("/open-apis/drive/v1/files/upload_all"). + Body(body) + } + + prepareBody := map[string]interface{}{ + "file_name": fileName, + "parent_type": "explorer", + "parent_node": spec.FolderToken, + "size": fileSize, + } + if spec.FileToken != "" { + prepareBody["file_token"] = spec.FileToken + } + + desc := "3-step multipart upload" + if spec.FileToken != "" { + desc = "3-step multipart overwrite upload" + } + + return common.NewDryRunAPI(). + Desc(desc). + POST("/open-apis/drive/v1/files/upload_prepare"). + Desc("[1] Initialize multipart upload"). + Body(prepareBody). + POST("/open-apis/drive/v1/files/upload_part"). + Desc("[2] Upload file parts (repeated)"). + Body(map[string]interface{}{ + "upload_id": "", + "seq": "", + "size": "", + "file": "", + }). + POST("/open-apis/drive/v1/files/upload_finish"). + Desc("[3] Finalize upload and get file_token/version"). + Body(map[string]interface{}{ + "upload_id": "", + "block_num": "", + }) +} + +func markdownOverwriteDryRun(spec markdownUploadSpec, fileSize int64, multipart bool) *common.DryRunAPI { + fileName := strings.TrimSpace(spec.FileName) + if fileName == "" && spec.FileSet { + fileName = finalMarkdownFileName(spec) + } + if fileName != "" { + spec.FileName = fileName + return markdownUploadDryRun(spec, fileSize, multipart) + } + + dry := common.NewDryRunAPI().Desc("Fetch the existing file name, then overwrite the file content") + dry.POST("/open-apis/drive/v1/metas/batch_query"). + Desc("[1] Read current file metadata to preserve the existing file name"). + Body(map[string]interface{}{ + "request_docs": []map[string]interface{}{ + { + "doc_token": spec.FileToken, + "doc_type": "file", + }, + }, + }) + + spec.FileName = "" + if !multipart { + dry.POST("/open-apis/drive/v1/files/upload_all"). + Desc("[2] Overwrite file contents with multipart/form-data upload"). + Body(map[string]interface{}{ + "file_name": spec.FileName, + "parent_type": "explorer", + "parent_node": spec.FolderToken, + "size": fileSize, + "file": markdownDryRunFileField(spec), + "file_token": spec.FileToken, + }) + return dry + } + + dry.POST("/open-apis/drive/v1/files/upload_prepare"). + Desc("[2] Initialize multipart overwrite upload"). + Body(map[string]interface{}{ + "file_name": spec.FileName, + "parent_type": "explorer", + "parent_node": spec.FolderToken, + "size": fileSize, + "file_token": spec.FileToken, + }). + POST("/open-apis/drive/v1/files/upload_part"). + Desc("[3] Upload file parts (repeated)"). + Body(map[string]interface{}{ + "upload_id": "", + "seq": "", + "size": "", + "file": "", + }). + POST("/open-apis/drive/v1/files/upload_finish"). + Desc("[4] Finalize upload and get file_token/version"). + Body(map[string]interface{}{ + "upload_id": "", + "block_num": "", + }) + return dry +} + +func uploadMarkdownFile(runtime *common.RuntimeContext, spec markdownUploadSpec, payload []byte) (markdownUploadResult, error) { + fileName := finalMarkdownFileName(spec) + fileSize := int64(len(payload)) + if fileSize > markdownSinglePartSizeLimit { + return uploadMarkdownFileMultipart(runtime, spec, payload, fileName, fileSize) + } + return uploadMarkdownFileAll(runtime, spec, payload, fileName, fileSize) +} + +func uploadMarkdownFileAll(runtime *common.RuntimeContext, spec markdownUploadSpec, payload []byte, fileName string, fileSize int64) (markdownUploadResult, error) { + fd := larkcore.NewFormdata() + fd.AddField("file_name", fileName) + fd.AddField("parent_type", "explorer") + fd.AddField("parent_node", spec.FolderToken) + fd.AddField("size", fmt.Sprintf("%d", fileSize)) + if spec.FileToken != "" { + fd.AddField("file_token", spec.FileToken) + } + fd.AddFile("file", bytes.NewReader(payload)) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/files/upload_all", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return markdownUploadResult{}, err + } + return markdownUploadResult{}, output.ErrNetwork("upload failed: %v", err) + } + + data, err := common.ParseDriveMediaUploadResponse(apiResp, "upload failed") + if err != nil { + return markdownUploadResult{}, err + } + return parseMarkdownUploadResult(data, spec.FileToken != "") +} + +func uploadMarkdownFileMultipart(runtime *common.RuntimeContext, spec markdownUploadSpec, payload []byte, fileName string, fileSize int64) (markdownUploadResult, error) { + prepareBody := map[string]interface{}{ + "file_name": fileName, + "parent_type": "explorer", + "parent_node": spec.FolderToken, + "size": fileSize, + } + if spec.FileToken != "" { + prepareBody["file_token"] = spec.FileToken + } + + prepareResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_prepare", nil, prepareBody) + if err != nil { + return markdownUploadResult{}, err + } + + session, err := parseMarkdownMultipartSession(prepareResult) + if err != nil { + return markdownUploadResult{}, err + } + + fmt.Fprintf(runtime.IO().ErrOut, "Multipart upload initialized: %d chunks x %s\n", session.BlockNum, common.FormatSize(session.BlockSize)) + + if err := uploadMarkdownMultipartParts(runtime, payload, session); err != nil { + return markdownUploadResult{}, err + } + + finishResult, err := runtime.CallAPI("POST", "/open-apis/drive/v1/files/upload_finish", nil, map[string]interface{}{ + "upload_id": session.UploadID, + "block_num": session.BlockNum, + }) + if err != nil { + return markdownUploadResult{}, err + } + + return parseMarkdownUploadResult(finishResult, spec.FileToken != "") +} + +func parseMarkdownMultipartSession(data map[string]interface{}) (markdownMultipartSession, error) { + session := markdownMultipartSession{ + UploadID: common.GetString(data, "upload_id"), + BlockSize: int64(common.GetFloat(data, "block_size")), + BlockNum: int(common.GetFloat(data, "block_num")), + } + if session.UploadID == "" || session.BlockSize <= 0 || session.BlockNum <= 0 { + return markdownMultipartSession{}, output.Errorf(output.ExitAPI, "api_error", + "upload_prepare returned invalid data: upload_id=%q, block_size=%d, block_num=%d", + session.UploadID, session.BlockSize, session.BlockNum) + } + return session, nil +} + +func uploadMarkdownMultipartParts(runtime *common.RuntimeContext, payload []byte, session markdownMultipartSession) error { + payloadSize := int64(len(payload)) + expectedBlocks := int((payloadSize + session.BlockSize - 1) / session.BlockSize) + if session.BlockNum != expectedBlocks { + return output.Errorf( + output.ExitAPI, + "api_error", + "upload_prepare returned inconsistent chunk plan: block_size=%d, block_num=%d, expected_block_num=%d, payload_size=%d", + session.BlockSize, + session.BlockNum, + expectedBlocks, + payloadSize, + ) + } + + reader := bytes.NewReader(payload) + buffer := make([]byte, int(session.BlockSize)) + remaining := payloadSize + + for seq := 0; seq < session.BlockNum; seq++ { + chunkSize := session.BlockSize + if remaining > 0 && chunkSize > remaining { + chunkSize = remaining + } + + n, readErr := io.ReadFull(reader, buffer[:int(chunkSize)]) + if readErr != nil { + return output.ErrValidation("cannot read file: %s", readErr) + } + + fd := larkcore.NewFormdata() + fd.AddField("upload_id", session.UploadID) + fd.AddField("seq", fmt.Sprintf("%d", seq)) + fd.AddField("size", fmt.Sprintf("%d", n)) + fd.AddFile("file", bytes.NewReader(buffer[:n])) + + apiResp, err := runtime.DoAPI(&larkcore.ApiReq{ + HttpMethod: http.MethodPost, + ApiPath: "/open-apis/drive/v1/files/upload_part", + Body: fd, + }, larkcore.WithFileUpload()) + if err != nil { + var exitErr *output.ExitError + if errors.As(err, &exitErr) { + return err + } + return output.ErrNetwork("upload part %d/%d failed: %v", seq+1, session.BlockNum, err) + } + + if _, err := common.ParseDriveMediaUploadResponse(apiResp, fmt.Sprintf("upload part %d/%d failed", seq+1, session.BlockNum)); err != nil { + return err + } + + fmt.Fprintf(runtime.IO().ErrOut, " Block %d/%d uploaded (%s)\n", seq+1, session.BlockNum, common.FormatSize(int64(n))) + remaining -= int64(n) + } + if remaining != 0 { + return output.Errorf( + output.ExitAPI, + "api_error", + "upload_prepare returned inconsistent chunk plan: %d bytes remain after %d blocks", + remaining, + session.BlockNum, + ) + } + + return nil +} + +func parseMarkdownUploadResult(data map[string]interface{}, requireVersion bool) (markdownUploadResult, error) { + result := markdownUploadResult{ + FileToken: common.GetString(data, "file_token"), + Version: common.GetString(data, "version"), + } + if result.Version == "" { + result.Version = common.GetString(data, "data_version") + } + if result.FileToken == "" { + return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned") + } + if requireVersion && result.Version == "" { + return markdownUploadResult{}, output.Errorf(output.ExitAPI, "api_error", "overwrite failed: no version returned") + } + return result, nil +} + +func fetchMarkdownFileName(runtime *common.RuntimeContext, fileToken string) (string, error) { + data, err := runtime.CallAPI( + "POST", + "/open-apis/drive/v1/metas/batch_query", + nil, + map[string]interface{}{ + "request_docs": []map[string]interface{}{ + { + "doc_token": fileToken, + "doc_type": "file", + }, + }, + }, + ) + if err != nil { + return "", err + } + + metas := common.GetSlice(data, "metas") + if len(metas) == 0 { + return "", nil + } + meta, _ := metas[0].(map[string]interface{}) + return common.GetString(meta, "title"), nil +} + +func prettyPrintMarkdownWrite(w io.Writer, data map[string]interface{}) { + fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token")) + fmt.Fprintf(w, "file_name: %s\n", common.GetString(data, "file_name")) + version := common.GetString(data, "version") + if version == "" { + version = common.GetString(data, "data_version") + } + if version != "" { + fmt.Fprintf(w, "version: %s\n", version) + } + fmt.Fprintf(w, "size_bytes: %d\n", int64(common.GetFloat(data, "size_bytes"))) + if grant := common.GetMap(data, "permission_grant"); grant != nil { + fmt.Fprintf(w, "permission_grant.status: %s\n", common.GetString(grant, "status")) + fmt.Fprintf(w, "permission_grant.perm: %s\n", common.GetString(grant, "perm")) + } +} + +func prettyPrintMarkdownSavedFile(w io.Writer, data map[string]interface{}) { + fmt.Fprintf(w, "file_token: %s\n", common.GetString(data, "file_token")) + 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 prettyPrintMarkdownContent(w io.Writer, data map[string]interface{}) { + fmt.Fprint(w, common.GetString(data, "content")) +} + +func fileNameFromDownloadHeader(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 +} diff --git a/shortcuts/markdown/markdown_create.go b/shortcuts/markdown/markdown_create.go new file mode 100644 index 000000000..daa05d85b --- /dev/null +++ b/shortcuts/markdown/markdown_create.go @@ -0,0 +1,86 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "context" + "io" + "strings" + + "github.com/larksuite/cli/shortcuts/common" +) + +var MarkdownCreate = common.Shortcut{ + Service: "markdown", + Command: "+create", + Description: "Create a Markdown file in Drive", + Risk: "write", + Scopes: []string{"drive:file:upload"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "folder-token", Desc: "target Drive folder token (default: root folder)"}, + {Name: "name", Desc: "file name with .md suffix; required with --content, optional with --file"}, + {Name: "content", Desc: "Markdown content", Input: []string{common.File, common.Stdin}}, + {Name: "file", Desc: "local .md file path"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + return validateMarkdownSpec(runtime, markdownUploadSpec{ + FileName: strings.TrimSpace(runtime.Str("name")), + FolderToken: strings.TrimSpace(runtime.Str("folder-token")), + FilePath: strings.TrimSpace(runtime.Str("file")), + FileSet: runtime.Changed("file"), + Content: runtime.Str("content"), + ContentSet: runtime.Changed("content"), + }, true) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec := markdownUploadSpec{ + FileName: strings.TrimSpace(runtime.Str("name")), + FolderToken: strings.TrimSpace(runtime.Str("folder-token")), + FilePath: strings.TrimSpace(runtime.Str("file")), + FileSet: runtime.Changed("file"), + Content: runtime.Str("content"), + ContentSet: runtime.Changed("content"), + } + payload, err := markdownSourceBytes(runtime, spec) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return markdownUploadDryRun(spec, int64(len(payload)), int64(len(payload)) > markdownSinglePartSizeLimit) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + spec := markdownUploadSpec{ + FileName: strings.TrimSpace(runtime.Str("name")), + FolderToken: strings.TrimSpace(runtime.Str("folder-token")), + FilePath: strings.TrimSpace(runtime.Str("file")), + FileSet: runtime.Changed("file"), + Content: runtime.Str("content"), + ContentSet: runtime.Changed("content"), + } + payload, err := markdownSourceBytes(runtime, spec) + if err != nil { + return err + } + + result, err := uploadMarkdownFile(runtime, spec, payload) + if err != nil { + return err + } + + out := map[string]interface{}{ + "file_token": result.FileToken, + "file_name": finalMarkdownFileName(spec), + "size_bytes": len(payload), + } + if grant := common.AutoGrantCurrentUserDrivePermission(runtime, result.FileToken, "file"); grant != nil { + out["permission_grant"] = grant + } + + runtime.OutFormat(out, nil, func(w io.Writer) { + prettyPrintMarkdownWrite(w, out) + }) + return nil + }, +} diff --git a/shortcuts/markdown/markdown_fetch.go b/shortcuts/markdown/markdown_fetch.go new file mode 100644 index 000000000..cfc9d8a85 --- /dev/null +++ b/shortcuts/markdown/markdown_fetch.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "context" + "fmt" + "io" + "net/http" + "path/filepath" + "strings" + + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + + "github.com/larksuite/cli/extension/fileio" + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var MarkdownFetch = common.Shortcut{ + Service: "markdown", + Command: "+fetch", + Description: "Fetch a Markdown file from Drive", + Risk: "read", + Scopes: []string{"drive:file:download"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "file-token", Desc: "Markdown file token", Required: true}, + {Name: "output", Desc: "local save path or directory; omit to return content directly"}, + {Name: "overwrite", Type: "bool", Desc: "overwrite existing local output file"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + fileToken := strings.TrimSpace(runtime.Str("file-token")) + if err := validate.ResourceName(fileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + outputPath := strings.TrimSpace(runtime.Str("output")) + if outputPath == "" { + return nil + } + if _, err := validate.SafeOutputPath(outputPath); err != nil { + return output.ErrValidation("unsafe output path: %s", err) + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + dry := common.NewDryRunAPI(). + Desc("download markdown file bytes; when --output is omitted the CLI returns content as UTF-8 text"). + GET("/open-apis/drive/v1/files/:file_token/download"). + Set("file_token", runtime.Str("file-token")) + if outputPath := strings.TrimSpace(runtime.Str("output")); outputPath != "" { + dry.Set("output", outputPath) + } else { + dry.Set("output", "") + } + return dry + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + fileToken := strings.TrimSpace(runtime.Str("file-token")) + outputPath := strings.TrimSpace(runtime.Str("output")) + + resp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{ + HttpMethod: http.MethodGet, + ApiPath: fmt.Sprintf("/open-apis/drive/v1/files/%s/download", validate.EncodePathSegment(fileToken)), + }) + if err != nil { + return output.ErrNetwork("download failed: %s", err) + } + defer resp.Body.Close() + + fileName := fileNameFromDownloadHeader(resp.Header, fileToken+".md") + if outputPath == "" { + payload, err := io.ReadAll(resp.Body) + if err != nil { + return output.ErrNetwork("download failed: %s", err) + } + out := map[string]interface{}{ + "file_token": fileToken, + "file_name": fileName, + "content": string(payload), + "size_bytes": len(payload), + } + runtime.OutFormatRaw(out, nil, func(w io.Writer) { + prettyPrintMarkdownContent(w, out) + }) + return nil + } + + if markdownFetchOutputIsDirectory(runtime, outputPath) { + outputPath = filepath.Join(outputPath, fileName) + } + if _, statErr := runtime.FileIO().Stat(outputPath); statErr == nil && !runtime.Bool("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": fileToken, + "file_name": fileName, + "saved_path": savedPath, + "size_bytes": result.Size(), + } + runtime.OutFormat(out, nil, func(w io.Writer) { + prettyPrintMarkdownSavedFile(w, out) + }) + return nil + }, +} + +func markdownFetchOutputIsDirectory(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() +} diff --git a/shortcuts/markdown/markdown_overwrite.go b/shortcuts/markdown/markdown_overwrite.go new file mode 100644 index 000000000..853414227 --- /dev/null +++ b/shortcuts/markdown/markdown_overwrite.go @@ -0,0 +1,108 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "context" + "io" + "path/filepath" + "strings" + + "github.com/larksuite/cli/internal/output" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +var MarkdownOverwrite = common.Shortcut{ + Service: "markdown", + Command: "+overwrite", + Description: "Overwrite an existing Markdown file in Drive", + Risk: "write", + Scopes: []string{"drive:file:upload", "drive:drive.metadata:readonly"}, + AuthTypes: []string{"user", "bot"}, + HasFormat: true, + Flags: []common.Flag{ + {Name: "file-token", Desc: "target Markdown file token", Required: true}, + {Name: "name", Desc: "optional file name with .md suffix; overrides the existing/local file name"}, + {Name: "content", Desc: "new Markdown content", Input: []string{common.File, common.Stdin}}, + {Name: "file", Desc: "local .md file path"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + fileToken := strings.TrimSpace(runtime.Str("file-token")) + if err := validate.ResourceName(fileToken, "--file-token"); err != nil { + return output.ErrValidation("%s", err) + } + return validateMarkdownSpec(runtime, markdownUploadSpec{ + FileToken: fileToken, + FileName: strings.TrimSpace(runtime.Str("name")), + FilePath: strings.TrimSpace(runtime.Str("file")), + FileSet: runtime.Changed("file"), + Content: runtime.Str("content"), + ContentSet: runtime.Changed("content"), + }, false) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + spec := markdownUploadSpec{ + FileToken: strings.TrimSpace(runtime.Str("file-token")), + FileName: strings.TrimSpace(runtime.Str("name")), + FilePath: strings.TrimSpace(runtime.Str("file")), + FileSet: runtime.Changed("file"), + Content: runtime.Str("content"), + ContentSet: runtime.Changed("content"), + } + payload, err := markdownSourceBytes(runtime, spec) + if err != nil { + return common.NewDryRunAPI().Set("error", err.Error()) + } + return markdownOverwriteDryRun(spec, int64(len(payload)), int64(len(payload)) > markdownSinglePartSizeLimit) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + fileToken := strings.TrimSpace(runtime.Str("file-token")) + spec := markdownUploadSpec{ + FileToken: fileToken, + FileName: strings.TrimSpace(runtime.Str("name")), + FilePath: strings.TrimSpace(runtime.Str("file")), + FileSet: runtime.Changed("file"), + Content: runtime.Str("content"), + ContentSet: runtime.Changed("content"), + } + + fileName := strings.TrimSpace(spec.FileName) + if fileName == "" && spec.FileSet { + fileName = filepath.Base(spec.FilePath) + } + if fileName == "" { + remoteName, err := fetchMarkdownFileName(runtime, fileToken) + if err != nil { + return err + } + fileName = strings.TrimSpace(remoteName) + } + if fileName == "" { + fileName = fileToken + ".md" + } + spec.FileName = fileName + + payload, err := markdownSourceBytes(runtime, spec) + if err != nil { + return err + } + + result, err := uploadMarkdownFile(runtime, spec, payload) + if err != nil { + return err + } + + out := map[string]interface{}{ + "file_token": result.FileToken, + "file_name": fileName, + "version": result.Version, + "size_bytes": len(payload), + } + runtime.OutFormat(out, nil, func(w io.Writer) { + prettyPrintMarkdownWrite(w, out) + }) + return nil + }, +} diff --git a/shortcuts/markdown/markdown_test.go b/shortcuts/markdown/markdown_test.go new file mode 100644 index 000000000..5927721d3 --- /dev/null +++ b/shortcuts/markdown/markdown_test.go @@ -0,0 +1,762 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "bytes" + "encoding/json" + "io" + "mime" + "mime/multipart" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/core" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" +) + +func markdownTestConfig() *core.CliConfig { + return &core.CliConfig{ + AppID: "markdown-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, + } +} + +func mountAndRunMarkdown(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error { + t.Helper() + parent := &cobra.Command{Use: "markdown"} + s.Mount(parent, f) + parent.SetArgs(args) + parent.SilenceErrors = true + parent.SilenceUsage = true + if stdout != nil { + stdout.Reset() + } + return parent.Execute() +} + +func withMarkdownWorkingDir(t *testing.T, dir string) { + t.Helper() + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("Getwd() error: %v", err) + } + if err := os.Chdir(dir); err != nil { + t.Fatalf("Chdir(%q) error: %v", dir, err) + } + t.Cleanup(func() { + if err := os.Chdir(cwd); err != nil { + t.Fatalf("restore cwd error: %v", err) + } + }) +} + +type capturedMultipartBody struct { + Fields map[string]string + Files map[string][]byte +} + +func decodeCapturedMultipartBody(t *testing.T, stub *httpmock.Stub) capturedMultipartBody { + t.Helper() + + contentType := stub.CapturedHeaders.Get("Content-Type") + mediaType, params, err := mime.ParseMediaType(contentType) + if err != nil { + t.Fatalf("parse multipart content type: %v", err) + } + if mediaType != "multipart/form-data" { + t.Fatalf("content type = %q, want multipart/form-data", mediaType) + } + + reader := multipart.NewReader(bytes.NewReader(stub.CapturedBody), params["boundary"]) + body := capturedMultipartBody{ + Fields: map[string]string{}, + Files: map[string][]byte{}, + } + for { + part, err := reader.NextPart() + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("read multipart part: %v", err) + } + + data, err := io.ReadAll(part) + if err != nil { + t.Fatalf("read multipart data: %v", err) + } + if part.FileName() != "" { + body.Files[part.FormName()] = data + continue + } + body.Fields[part.FormName()] = string(data) + } + return body +} + +func TestShortcutsIncludesExpectedCommands(t *testing.T) { + t.Parallel() + + got := Shortcuts() + want := []string{"+create", "+fetch", "+overwrite"} + + if len(got) != len(want) { + t.Fatalf("len(Shortcuts()) = %d, want %d", len(got), len(want)) + } + + seen := make(map[string]bool, len(got)) + for _, shortcut := range got { + if seen[shortcut.Command] { + t.Fatalf("duplicate shortcut command: %s", shortcut.Command) + } + seen[shortcut.Command] = true + } + + for _, command := range want { + if !seen[command] { + t.Fatalf("missing shortcut command %q in Shortcuts()", command) + } + } +} + +func TestMarkdownCreateRequiresNameWithContent(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig()) + + err := mountAndRunMarkdown(t, MarkdownCreate, []string{ + "+create", + "--content", "# hello", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--name is required when using --content") { + t.Fatalf("expected name validation error, got %v", err) + } +} + +func TestMarkdownCreateRejectsNonMarkdownFile(t *testing.T) { + f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig()) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + if err := os.WriteFile("note.txt", []byte("hello"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunMarkdown(t, MarkdownCreate, []string{ + "+create", + "--file", "note.txt", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "--file must end with .md") { + t.Fatalf("expected .md validation error, got %v", err) + } +} + +func TestMarkdownCreateAllowsEmptyInlineContent(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "file_token": "box_md_empty_inline", + "version": "1002", + }, + }, + } + reg.Register(uploadStub) + + err := mountAndRunMarkdown(t, MarkdownCreate, []string{ + "+create", + "--name", "empty.md", + "--content", "", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeCapturedMultipartBody(t, uploadStub) + if got := body.Fields["size"]; got != "0" { + t.Fatalf("size = %q, want 0", got) + } + if got := string(body.Files["file"]); got != "" { + t.Fatalf("uploaded file content = %q, want empty string", got) + } + if !strings.Contains(stdout.String(), `"size_bytes": 0`) { + t.Fatalf("stdout missing zero size_bytes: %s", stdout.String()) + } +} + +func TestMarkdownCreateAllowsEmptyContentFromFileInput(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "file_token": "box_md_empty_file_input", + "version": "1003", + }, + }, + } + reg.Register(uploadStub) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + if err := os.WriteFile("empty.md", []byte{}, 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunMarkdown(t, MarkdownCreate, []string{ + "+create", + "--name", "empty.md", + "--content", "@./empty.md", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeCapturedMultipartBody(t, uploadStub) + if got := body.Fields["size"]; got != "0" { + t.Fatalf("size = %q, want 0", got) + } + if got := string(body.Files["file"]); got != "" { + t.Fatalf("uploaded file content = %q, want empty string", got) + } +} + +func TestMarkdownCreateSuccessUploadAll(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "file_token": "box_md_create", + "version": "1001", + }, + }, + } + reg.Register(uploadStub) + + err := mountAndRunMarkdown(t, MarkdownCreate, []string{ + "+create", + "--name", "README.md", + "--content", "# hello\n", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeCapturedMultipartBody(t, uploadStub) + if got := body.Fields["file_name"]; got != "README.md" { + t.Fatalf("file_name = %q, want README.md", got) + } + if got := body.Fields["parent_type"]; got != "explorer" { + t.Fatalf("parent_type = %q, want explorer", got) + } + if got := body.Fields["parent_node"]; got != "" { + t.Fatalf("parent_node = %q, want empty root folder", got) + } + if _, exists := body.Fields["file_token"]; exists { + t.Fatalf("did not expect file_token on create upload_all body") + } + if got := string(body.Files["file"]); got != "# hello\n" { + t.Fatalf("uploaded file content = %q", got) + } + if !strings.Contains(stdout.String(), `"file_token": "box_md_create"`) { + t.Fatalf("stdout missing file_token: %s", stdout.String()) + } + if !strings.Contains(stdout.String(), `"file_name": "README.md"`) { + t.Fatalf("stdout missing file_name: %s", stdout.String()) + } +} + +func TestMarkdownCreateFailsWhenMultipartPlanIsTooSmall(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_prepare", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "upload_id": "upload_markdown_bad_plan", + "block_size": float64(markdownSinglePartSizeLimit), + "block_num": float64(1), + }, + }, + }) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + fh, err := os.Create("large.md") + if err != nil { + t.Fatalf("Create() error: %v", err) + } + if err := fh.Truncate(markdownSinglePartSizeLimit + 1); err != nil { + fh.Close() + t.Fatalf("Truncate() error: %v", err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close() error: %v", err) + } + + err = mountAndRunMarkdown(t, MarkdownCreate, []string{ + "+create", + "--file", "large.md", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "inconsistent chunk plan") { + t.Fatalf("expected inconsistent chunk plan error, got %v", err) + } +} + +func TestMarkdownCreateFailsWhenMultipartPlanIsTooLarge(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_prepare", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "upload_id": "upload_markdown_bad_plan_large", + "block_size": float64(markdownSinglePartSizeLimit), + "block_num": float64(3), + }, + }, + }) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + fh, err := os.Create("large.md") + if err != nil { + t.Fatalf("Create() error: %v", err) + } + if err := fh.Truncate(markdownSinglePartSizeLimit + 1); err != nil { + fh.Close() + t.Fatalf("Truncate() error: %v", err) + } + if err := fh.Close(); err != nil { + t.Fatalf("Close() error: %v", err) + } + + err = mountAndRunMarkdown(t, MarkdownCreate, []string{ + "+create", + "--file", "large.md", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "inconsistent chunk plan") { + t.Fatalf("expected inconsistent chunk plan error, got %v", err) + } +} + +func TestMarkdownOverwriteUploadAllIncludesFileTokenAndVersion(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/metas/batch_query", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "metas": []map[string]interface{}{ + {"title": "README.md"}, + }, + }, + }, + }) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "file_token": "box_md_existing", + "version": "7633658129540910621", + }, + }, + } + reg.Register(uploadStub) + + err := mountAndRunMarkdown(t, MarkdownOverwrite, []string{ + "+overwrite", + "--file-token", "box_md_existing", + "--content", "# updated\n", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeCapturedMultipartBody(t, uploadStub) + if got := body.Fields["file_token"]; got != "box_md_existing" { + t.Fatalf("file_token = %q, want box_md_existing", got) + } + if got := body.Fields["file_name"]; got != "README.md" { + t.Fatalf("file_name = %q, want README.md", got) + } + if got := string(body.Files["file"]); got != "# updated\n" { + t.Fatalf("uploaded file content = %q", got) + } + if !strings.Contains(stdout.String(), `"version": "7633658129540910621"`) { + t.Fatalf("stdout missing version: %s", stdout.String()) + } +} + +func TestMarkdownOverwriteUsesExplicitNameWhenProvided(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "file_token": "box_md_existing", + "version": "7633658129540910622", + }, + }, + } + reg.Register(uploadStub) + + err := mountAndRunMarkdown(t, MarkdownOverwrite, []string{ + "+overwrite", + "--file-token", "box_md_existing", + "--name", "Renamed.md", + "--content", "# updated\n", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeCapturedMultipartBody(t, uploadStub) + if got := body.Fields["file_name"]; got != "Renamed.md" { + t.Fatalf("file_name = %q, want Renamed.md", got) + } + if !strings.Contains(stdout.String(), `"file_name": "Renamed.md"`) { + t.Fatalf("stdout missing overridden file_name: %s", stdout.String()) + } +} + +func TestMarkdownOverwriteUsesLocalFileNameByDefault(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "file_token": "box_md_existing", + "version": "7633658129540910623", + }, + }, + } + reg.Register(uploadStub) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + if err := os.WriteFile("local-name.md", []byte("# local\n"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunMarkdown(t, MarkdownOverwrite, []string{ + "+overwrite", + "--file-token", "box_md_existing", + "--file", "local-name.md", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeCapturedMultipartBody(t, uploadStub) + if got := body.Fields["file_name"]; got != "local-name.md" { + t.Fatalf("file_name = %q, want local-name.md", got) + } +} + +func TestMarkdownOverwriteFailsWithoutVersion(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/metas/batch_query", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "metas": []map[string]interface{}{ + {"title": "README.md"}, + }, + }, + }, + }) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "file_token": "box_md_existing", + }, + }, + }) + + err := mountAndRunMarkdown(t, MarkdownOverwrite, []string{ + "+overwrite", + "--file-token", "box_md_existing", + "--content", "# updated\n", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "overwrite failed: no version returned") { + t.Fatalf("expected version error, got %v", err) + } +} + +func TestMarkdownOverwriteFallsBackToFileTokenNameWhenMetadataMissing(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/metas/batch_query", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "metas": []map[string]interface{}{}, + }, + }, + }) + uploadStub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/drive/v1/files/upload_all", + Body: map[string]interface{}{ + "code": 0, + "data": map[string]interface{}{ + "file_token": "box_md_existing", + "version": "7633658129540910624", + }, + }, + } + reg.Register(uploadStub) + + err := mountAndRunMarkdown(t, MarkdownOverwrite, []string{ + "+overwrite", + "--file-token", "box_md_existing", + "--content", "# updated\n", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + body := decodeCapturedMultipartBody(t, uploadStub) + if got := body.Fields["file_name"]; got != "box_md_existing.md" { + t.Fatalf("file_name = %q, want box_md_existing.md", got) + } +} + +func TestMarkdownFetchReturnsContent(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_fetch/download", + Status: 200, + RawBody: []byte("# hello\n"), + Headers: map[string][]string{ + "Content-Type": {"text/plain"}, + "Content-Disposition": {`attachment; filename="README.md"`}, + }, + }) + + err := mountAndRunMarkdown(t, MarkdownFetch, []string{ + "+fetch", + "--file-token", "box_md_fetch", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(stdout.String(), `"file_name": "README.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 TestMarkdownFetchSavesFile(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_fetch/download", + Status: 200, + RawBody: []byte("# hello\n"), + Headers: map[string][]string{ + "Content-Type": {"text/plain"}, + "Content-Disposition": {`attachment; filename="README.md"`}, + }, + }) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + + err := mountAndRunMarkdown(t, MarkdownFetch, []string{ + "+fetch", + "--file-token", "box_md_fetch", + "--output", "copy.md", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile("copy.md") + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "# hello\n" { + t.Fatalf("saved content = %q", string(data)) + } + + 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.GetString(envelope.Data, "saved_path"); !strings.HasSuffix(got, "copy.md") { + t.Fatalf("saved_path = %q, want suffix copy.md", got) + } +} + +func TestMarkdownFetchRejectsExistingFileWithoutOverwrite(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_fetch/download", + Status: 200, + RawBody: []byte("# hello\n"), + Headers: map[string][]string{ + "Content-Type": {"text/plain"}, + "Content-Disposition": {`attachment; filename="README.md"`}, + }, + }) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + if err := os.WriteFile("copy.md", []byte("existing"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunMarkdown(t, MarkdownFetch, []string{ + "+fetch", + "--file-token", "box_md_fetch", + "--output", "copy.md", + }, f, stdout) + if err == nil || !strings.Contains(err.Error(), "output file already exists") { + t.Fatalf("expected output exists error, got %v", err) + } +} + +func TestMarkdownFetchOverwritesExistingFileWhenRequested(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_fetch/download", + Status: 200, + RawBody: []byte("# hello\n"), + Headers: map[string][]string{ + "Content-Type": {"text/plain"}, + "Content-Disposition": {`attachment; filename="README.md"`}, + }, + }) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + if err := os.WriteFile("copy.md", []byte("existing"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + err := mountAndRunMarkdown(t, MarkdownFetch, []string{ + "+fetch", + "--file-token", "box_md_fetch", + "--output", "copy.md", + "--overwrite", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile("copy.md") + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "# hello\n" { + t.Fatalf("saved content = %q", string(data)) + } +} + +func TestMarkdownFetchSavesUsingRemoteNameWhenOutputIsExistingDirectory(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_fetch/download", + Status: 200, + RawBody: []byte("# hello\n"), + Headers: map[string][]string{ + "Content-Type": {"text/plain"}, + "Content-Disposition": {`attachment; filename="README.md"`}, + }, + }) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + if err := os.MkdirAll("downloads", 0o755); err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + + err := mountAndRunMarkdown(t, MarkdownFetch, []string{ + "+fetch", + "--file-token", "box_md_fetch", + "--output", "downloads", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join("downloads", "README.md")) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "# hello\n" { + t.Fatalf("saved content = %q", string(data)) + } +} + +func TestMarkdownFetchSavesUsingRemoteNameWhenOutputUsesDirectorySyntax(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, markdownTestConfig()) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/drive/v1/files/box_md_fetch/download", + Status: 200, + RawBody: []byte("# hello\n"), + Headers: map[string][]string{ + "Content-Type": {"text/plain"}, + "Content-Disposition": {`attachment; filename="README.md"`}, + }, + }) + + tmpDir := t.TempDir() + withMarkdownWorkingDir(t, tmpDir) + + err := mountAndRunMarkdown(t, MarkdownFetch, []string{ + "+fetch", + "--file-token", "box_md_fetch", + "--output", "downloads/", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data, err := os.ReadFile(filepath.Join("downloads", "README.md")) + if err != nil { + t.Fatalf("ReadFile() error: %v", err) + } + if string(data) != "# hello\n" { + t.Fatalf("saved content = %q", string(data)) + } +} diff --git a/shortcuts/markdown/shortcuts.go b/shortcuts/markdown/shortcuts.go new file mode 100644 index 000000000..5bc2d02ad --- /dev/null +++ b/shortcuts/markdown/shortcuts.go @@ -0,0 +1,15 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package markdown + +import "github.com/larksuite/cli/shortcuts/common" + +// Shortcuts returns all markdown shortcuts. +func Shortcuts() []common.Shortcut { + return []common.Shortcut{ + MarkdownCreate, + MarkdownFetch, + MarkdownOverwrite, + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 534163ec2..5669fa3f3 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -20,6 +20,7 @@ import ( "github.com/larksuite/cli/shortcuts/event" "github.com/larksuite/cli/shortcuts/im" "github.com/larksuite/cli/shortcuts/mail" + "github.com/larksuite/cli/shortcuts/markdown" "github.com/larksuite/cli/shortcuts/minutes" "github.com/larksuite/cli/shortcuts/sheets" "github.com/larksuite/cli/shortcuts/slides" @@ -42,6 +43,7 @@ func init() { allShortcuts = append(allShortcuts, base.Shortcuts()...) allShortcuts = append(allShortcuts, event.Shortcuts()...) allShortcuts = append(allShortcuts, mail.Shortcuts()...) + allShortcuts = append(allShortcuts, markdown.Shortcuts()...) allShortcuts = append(allShortcuts, slides.Shortcuts()...) allShortcuts = append(allShortcuts, minutes.Shortcuts()...) allShortcuts = append(allShortcuts, task.Shortcuts()...) diff --git a/shortcuts/register_markdown_test.go b/shortcuts/register_markdown_test.go new file mode 100644 index 000000000..7a622204e --- /dev/null +++ b/shortcuts/register_markdown_test.go @@ -0,0 +1,30 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package shortcuts + +import ( + "testing" + + "github.com/larksuite/cli/internal/cmdutil" + "github.com/spf13/cobra" +) + +func TestRegisterShortcutsMountsMarkdownCommands(t *testing.T) { + program := &cobra.Command{Use: "root"} + RegisterShortcuts(program, &cmdutil.Factory{}) + + for _, path := range [][]string{ + {"markdown", "+create"}, + {"markdown", "+fetch"}, + {"markdown", "+overwrite"}, + } { + cmd, _, err := program.Find(path) + if err != nil { + t.Fatalf("find markdown shortcut %v: %v", path, err) + } + if cmd == nil || cmd.Name() != path[1] { + t.Fatalf("markdown shortcut not mounted: %#v", cmd) + } + } +} diff --git a/skills/lark-drive/SKILL.md b/skills/lark-drive/SKILL.md index cda756944..b3f5627f8 100644 --- a/skills/lark-drive/SKILL.md +++ b/skills/lark-drive/SKILL.md @@ -19,6 +19,8 @@ metadata: - 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"、"最近一周我打开过的 xxx"、"某人创建的 docx" 等直接映射到扁平 flag,避免手写嵌套 JSON。老的 `docs +search` 进入维护期、后续会下线,不要新增对它的依赖。 - 用户要把本地 `.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 bot`。 - 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。 - 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。 - 用户要把本地文件上传到知识库 / 文档库里的某个 wiki 节点下时,仍然使用 `lark-cli drive +upload --wiki-token `;不要误切到 `wiki` 域命令。 @@ -233,6 +235,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 | | [`+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 | | [`+task_result`](references/lark-drive-task-result.md) | Poll async task result for import, export, move, or delete operations | diff --git a/skills/lark-drive/references/lark-drive-upload.md b/skills/lark-drive/references/lark-drive-upload.md index fc19ba886..1752e9860 100644 --- a/skills/lark-drive/references/lark-drive-upload.md +++ b/skills/lark-drive/references/lark-drive-upload.md @@ -5,6 +5,9 @@ 上传本地文件到飞书云空间。目标位置可以是 Drive 文件夹,也可以是 wiki 节点。 +## 快速决策 +- 用户要在 Drive 里上传、创建、读取、覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。 + ## 命令 ```bash 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..9f577b4c9 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-version-delete.md @@ -0,0 +1,30 @@ +# drive +version-delete + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +删除指定的历史版本。当前 shortcut 推荐使用应用身份:`--as bot`。 + +## 命令 + +```bash +lark-cli drive +version-delete \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --as bot +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标文件 token | +| `--version` | 是 | 要删除的版本号 | + +## 返回值 + +无额外业务字段,以命令成功 / 失败为准。 + +## 参考 + +- [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..40b8f6c29 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-version-get.md @@ -0,0 +1,51 @@ +# drive +version-get + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +下载指定版本的文件内容。当前 shortcut 推荐使用应用身份:`--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 \ + --output ./artifact.bin \ + --overwrite \ + --as bot +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标文件 token | +| `--version` | 是 | 目标版本号 | +| `--output` | 否 | 本地保存路径;省略时沿用 `drive +download` 的默认落点行为 | +| `--overwrite` | 否 | 覆盖已存在的本地输出文件 | + +## 返回值 + +```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..1fcc7313f --- /dev/null +++ b/skills/lark-drive/references/lark-drive-version-history.md @@ -0,0 +1,66 @@ +# drive +version-history + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +列出指定文件的历史版本快照。当前 shortcut 推荐使用应用身份:`--as bot`。 + +## 命令 + +```bash +lark-cli drive +version-history \ + --file-token boxcnxxxxxxxx \ + --as bot + +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` 继续翻页 + +## 返回值 + +```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", + "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..bdcd83c59 --- /dev/null +++ b/skills/lark-drive/references/lark-drive-version-revert.md @@ -0,0 +1,30 @@ +# drive +version-revert + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +将文件回滚到指定历史版本。当前 shortcut 推荐使用应用身份:`--as bot`。 + +## 命令 + +```bash +lark-cli drive +version-revert \ + --file-token boxcnxxxxxxxx \ + --version 7633658129540910621 \ + --as bot +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标文件 token | +| `--version` | 是 | 要回滚到的版本号 | + +## 返回值 + +无额外业务字段,以命令成功 / 失败为准。 + +## 参考 + +- [lark-drive](../SKILL.md) -- 云空间全部命令 +- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数 diff --git a/skills/lark-markdown/SKILL.md b/skills/lark-markdown/SKILL.md new file mode 100644 index 000000000..8f8893680 --- /dev/null +++ b/skills/lark-markdown/SKILL.md @@ -0,0 +1,44 @@ +--- +name: lark-markdown +version: 1.0.0 +description: "飞书云空间中的原生 Markdown 文件:创建、读取、覆盖更新 `.md` 文件。适用于 Drive 中作为普通文件存储的 Markdown,不适用于导入成在线 docx 文档。" +metadata: + requires: + bins: ["lark-cli"] + cliHelp: "lark-cli markdown --help" +--- + +# markdown + +**CRITICAL — 开始前 MUST 先用 Read 工具读取 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),其中包含认证、权限处理** + +## 快速决策 + +- 用户要**上传、创建一个原生 `.md` 文件**,使用 `lark-cli markdown +create` +- 用户要**读取 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +fetch` +- 用户要**覆盖更新 Drive 里某个 `.md` 文件内容**,使用 `lark-cli markdown +overwrite` +- 用户要把本地 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) + +## 核心边界 + +- 本 skill 处理的是 **Drive 中作为普通文件存储的 Markdown**,不是 docx 文档 +- `--name` 和本地 `--file` 文件名都必须显式带 `.md` 后缀;不满足时 shortcut 会直接报错 +- `--content` 支持: + - 直接传字符串 + - `@file` 从本地文件读取内容 + - `-` 从 stdin 读取内容 +- `--file` 只接受本地 `.md` 文件路径 + +## Shortcuts(推荐优先使用) + +| Shortcut | 说明 | +|----------|------| +| [`+create`](references/lark-markdown-create.md) | Create a Drive-native Markdown file (`.md`) | +| [`+fetch`](references/lark-markdown-fetch.md) | Fetch Markdown file content or save it locally | +| [`+overwrite`](references/lark-markdown-overwrite.md) | Overwrite an existing Drive-native Markdown file and return the new version | + +## 参考 + +- [lark-shared](../lark-shared/SKILL.md) — 认证和全局参数 +- [lark-drive](../lark-drive/SKILL.md) — Drive 文件管理、导入 docx、move/delete/search 等 diff --git a/skills/lark-markdown/references/lark-markdown-create.md b/skills/lark-markdown/references/lark-markdown-create.md new file mode 100644 index 000000000..b42c3330f --- /dev/null +++ b/skills/lark-markdown/references/lark-markdown-create.md @@ -0,0 +1,76 @@ +# markdown +create + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +在 Drive 中创建一个原生 Markdown 文件(`.md`)。 + +## 命令 + +```bash +# 直接用行内内容创建 +lark-cli markdown +create \ + --name README.md \ + --content '# Hello' + +# 从本地 .md 文件创建 +lark-cli markdown +create \ + --file ./README.md + +# 从本地文件读取内容,但仍走 --content +lark-cli markdown +create \ + --name README.md \ + --content @./README.md + +# 从 stdin 读取内容 +printf '# Hello\n\nfrom stdin\n' | \ + lark-cli markdown +create \ + --name README.md \ + --content - + +# 创建到指定文件夹 +lark-cli markdown +create \ + --folder-token fldcn_xxx \ + --file ./README.md + +# 预览底层请求 +lark-cli markdown +create \ + --name README.md \ + --content '# Hello' \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--folder-token` | 否 | 目标 Drive 文件夹 token;省略时创建到根目录 | +| `--name` | 条件必填 | 文件名,**必须显式带 `.md` 后缀**;使用 `--content` 时必填;使用 `--file` 时可省略,默认取本地文件名 | +| `--content` | 条件必填 | Markdown 内容;与 `--file` 互斥;支持直接传字符串、`@file`、`-`(stdin) | +| `--file` | 条件必填 | 本地 `.md` 文件路径;与 `--content` 互斥 | + +## 关键约束 + +- `--content` 与 `--file` 必须二选一 +- `--name` 必须带 `.md` 后缀 +- `--file` 指向的本地文件名也必须带 `.md` 后缀 + +## 返回值 + +```json +{ + "ok": true, + "identity": "user", + "data": { + "file_token": "boxcnxxxx", + "file_name": "README.md", + "size_bytes": 1234 + } +} +``` + +当以 `--as bot` 创建且自动为当前 CLI 用户授予可管理权限时,结果还会额外带上 `permission_grant`。 + +## 参考 + +- [lark-markdown](../SKILL.md) — Markdown 域总览 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-markdown/references/lark-markdown-fetch.md b/skills/lark-markdown/references/lark-markdown-fetch.md new file mode 100644 index 000000000..d6640dd3c --- /dev/null +++ b/skills/lark-markdown/references/lark-markdown-fetch.md @@ -0,0 +1,79 @@ +# markdown +fetch + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +读取 Drive 中原生 Markdown 文件的内容;也支持把内容保存到本地。 + +## 命令 + +```bash +# 直接返回 Markdown 文本 +lark-cli markdown +fetch --file-token boxcnxxxx + +# 保存到本地 +lark-cli markdown +fetch \ + --file-token boxcnxxxx \ + --output ./README.md + +# 传目录时,使用远端文件名保存到该目录下 +lark-cli markdown +fetch \ + --file-token boxcnxxxx \ + --output ./downloads + +# 覆盖已存在文件 +lark-cli markdown +fetch \ + --file-token boxcnxxxx \ + --output ./README.md \ + --overwrite + +# 预览底层请求 +lark-cli markdown +fetch \ + --file-token boxcnxxxx \ + --output ./README.md \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标 Markdown 文件 token | +| `--output` | 否 | 本地保存路径;既可传具体文件名,也可传目录路径。传目录时使用远端文件名保存;省略时直接返回 Markdown 内容 | +| `--overwrite` | 否 | 覆盖已存在的本地输出文件;仅在传入 `--output` 时生效 | + +## 返回值 + +不传 `--output`: + +```json +{ + "ok": true, + "identity": "user", + "data": { + "file_token": "boxcnxxxx", + "file_name": "README.md", + "content": "# Hello\n", + "size_bytes": 8 + } +} +``` + +传入 `--output`: + +```json +{ + "ok": true, + "identity": "user", + "data": { + "file_token": "boxcnxxxx", + "file_name": "README.md", + "saved_path": "/abs/path/README.md", + "size_bytes": 8 + } +} +``` + +## 参考 + +- [lark-markdown](../SKILL.md) — Markdown 域总览 +- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/lark-markdown/references/lark-markdown-overwrite.md b/skills/lark-markdown/references/lark-markdown-overwrite.md new file mode 100644 index 000000000..2326659f7 --- /dev/null +++ b/skills/lark-markdown/references/lark-markdown-overwrite.md @@ -0,0 +1,85 @@ +# markdown +overwrite + +> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +覆盖更新 Drive 中已有的原生 Markdown 文件,并返回覆盖后的新版本号。 + +## 命令 + +```bash +# 用行内内容覆盖 +lark-cli markdown +overwrite \ + --file-token boxcnxxxx \ + --content '# Updated' + +# 用本地 .md 文件覆盖 +lark-cli markdown +overwrite \ + --file-token boxcnxxxx \ + --file ./README.md + +# 覆盖内容时顺便显式指定新文件名 +lark-cli markdown +overwrite \ + --file-token boxcnxxxx \ + --name NEW-README.md \ + --content '# Updated' + +# 用 --content 从本地文件读取 +lark-cli markdown +overwrite \ + --file-token boxcnxxxx \ + --content @./README.md + +# 用 stdin 覆盖 +printf '# Updated\n' | \ + lark-cli markdown +overwrite \ + --file-token boxcnxxxx \ + --content - + +# 预览底层请求 +lark-cli markdown +overwrite \ + --file-token boxcnxxxx \ + --content '# Updated' \ + --dry-run +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--file-token` | 是 | 目标 Markdown 文件 token | +| `--name` | 否 | 显式指定覆盖后的文件名;必须带 `.md` 后缀。传入时优先使用它 | +| `--content` | 条件必填 | 新 Markdown 内容;与 `--file` 互斥;支持直接传字符串、`@file`、`-`(stdin) | +| `--file` | 条件必填 | 本地 `.md` 文件路径;与 `--content` 互斥 | + +## 关键约束 + +- `--content` 与 `--file` 必须二选一 +- 如果传了 `--name`,直接使用它作为覆盖后的文件名 +- 如果没传 `--name` 且使用 `--content`,默认保留远端原文件名 +- 如果没传 `--name` 且使用 `--file`,默认使用本地文件名 +- `--file` 指向的本地文件名必须带 `.md` 后缀 +- 覆盖成功后 **必须** 返回 `version` + +## 返回值 + +```json +{ + "ok": true, + "identity": "user", + "data": { + "file_token": "boxcnxxxx", + "file_name": "README.md", + "version": "7633658129540910621", + "size_bytes": 2048 + } +} +``` + +其中: + +- `version` 是覆盖写入后的新版本号 +- `size_bytes` 是本次覆盖后的内容大小 + +## 参考 + +- [lark-markdown](../SKILL.md) — Markdown 域总览 +- [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..7ce775636 --- /dev/null +++ b/tests/cli_e2e/drive/drive_version_dryrun_test.go @@ -0,0 +1,116 @@ +// 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 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"`) +} 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) +} diff --git a/tests/cli_e2e/markdown/markdown_dryrun_test.go b/tests/cli_e2e/markdown/markdown_dryrun_test.go new file mode 100644 index 000000000..a07c8f33d --- /dev/null +++ b/tests/cli_e2e/markdown/markdown_dryrun_test.go @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +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" +) + +func TestMarkdownCreateDryRun_Content(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", "+create", + "--name", "README.md", + "--content", "# hello", + "--folder-token", "fldcnMarkdownDryRun", + "--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/upload_all") + assert.Contains(t, output, `"file_name": "README.md"`) + assert.Contains(t, output, `"parent_node": "fldcnMarkdownDryRun"`) + assert.Contains(t, output, `"parent_type": "explorer"`) + assert.Contains(t, output, `"size": 7`) +} + +func TestMarkdownCreateDryRun_FileShowsConcreteSize(t *testing.T) { + setMarkdownDryRunConfigEnv(t) + + dir := t.TempDir() + content := "# hi\n" + require.NoError(t, os.WriteFile(dir+"/note.md", []byte(content), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+create", + "--file", "note.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/upload_all") + assert.Contains(t, output, `"file": "@note.md"`) + assert.Contains(t, output, `"size": 5`) +} + +func TestMarkdownFetchDryRun_OutputFile(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", "+fetch", + "--file-token", "boxcnMarkdownDryRun", + "--output", "./copy.md", + "--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, `"output": "./copy.md"`) +} + +func TestMarkdownOverwriteDryRun_ContentFile(t *testing.T) { + setMarkdownDryRunConfigEnv(t) + dir := t.TempDir() + content := "# overwrite test\n" + require.NoError(t, os.WriteFile(dir+"/input.md", []byte(content), 0o644)) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + t.Cleanup(cancel) + + result, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+overwrite", + "--file-token", "boxcnMarkdownDryRun", + "--content", "@input.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/metas/batch_query") + assert.Contains(t, output, "/open-apis/drive/v1/files/upload_all") + assert.Contains(t, output, `"file_token": "boxcnMarkdownDryRun"`) + assert.Contains(t, output, `"size": 17`) +} + +func setMarkdownDryRunConfigEnv(t *testing.T) { + t.Helper() + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + t.Setenv("LARKSUITE_CLI_APP_ID", "markdown_dryrun_test") + t.Setenv("LARKSUITE_CLI_APP_SECRET", "markdown_dryrun_secret") + t.Setenv("LARKSUITE_CLI_BRAND", "feishu") +} diff --git a/tests/cli_e2e/markdown/markdown_workflow_test.go b/tests/cli_e2e/markdown/markdown_workflow_test.go new file mode 100644 index 000000000..149a85f0e --- /dev/null +++ b/tests/cli_e2e/markdown/markdown_workflow_test.go @@ -0,0 +1,100 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package markdown + +import ( + "context" + "os" + "testing" + "time" + + clie2e "github.com/larksuite/cli/tests/cli_e2e" + "github.com/stretchr/testify/require" + "github.com/tidwall/gjson" +) + +func TestMarkdownLifecycleWorkflow(t *testing.T) { + if os.Getenv("LARK_MARKDOWN_E2E") == "" { + t.Skip("set LARK_MARKDOWN_E2E=1 to run markdown live workflow after backend version support is deployed") + } + t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) + clie2e.SkipWithoutUserToken(t) + + parentT := t + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + t.Cleanup(cancel) + + suffix := clie2e.GenerateSuffix() + fileName := "lark-cli-e2e-markdown-" + suffix + ".md" + initialContent := "# Initial\n\nhello markdown workflow\n" + updatedContent := "# Updated\n\nnew body\n" + + createResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+create", + "--name", fileName, + "--content", initialContent, + }, + DefaultAs: "user", + }) + 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: "user", + }) + clie2e.ReportCleanupFailure(parentT, "delete markdown file "+fileToken, deleteResult, deleteErr) + }) + + fetchInitialResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+fetch", + "--file-token", fileToken, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + fetchInitialResult.AssertExitCode(t, 0) + fetchInitialResult.AssertStdoutStatus(t, true) + require.Equal(t, initialContent, gjson.Get(fetchInitialResult.Stdout, "data.content").String(), "stdout:\n%s", fetchInitialResult.Stdout) + + overwriteResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+overwrite", + "--file-token", fileToken, + "--content", updatedContent, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + overwriteResult.AssertExitCode(t, 0) + overwriteResult.AssertStdoutStatus(t, true) + require.NotEmpty(t, gjson.Get(overwriteResult.Stdout, "data.version").String(), "stdout:\n%s", overwriteResult.Stdout) + + fetchUpdatedResult, err := clie2e.RunCmd(ctx, clie2e.Request{ + Args: []string{ + "markdown", "+fetch", + "--file-token", fileToken, + }, + DefaultAs: "user", + }) + require.NoError(t, err) + fetchUpdatedResult.AssertExitCode(t, 0) + fetchUpdatedResult.AssertStdoutStatus(t, true) + require.Equal(t, updatedContent, gjson.Get(fetchUpdatedResult.Stdout, "data.content").String(), "stdout:\n%s", fetchUpdatedResult.Stdout) +}