diff --git a/.gitignore b/.gitignore index 5666621b5..645848906 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ Thumbs.db # Go docs/ref docs/ +!tests/cli_e2e/docs/ +!tests/cli_e2e/docs/*.go +!tests/cli_e2e/docs/*.md vendor/ diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go index fa91d8912..09cb33077 100644 --- a/cmd/api/api_test.go +++ b/cmd/api/api_test.go @@ -20,13 +20,28 @@ import ( "github.com/spf13/cobra" ) +func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command { + cmd := NewCmdApi(f, runF) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + return cmd +} + +func newTestRootCmd() *cobra.Command { + return &cobra.Command{ + Use: "lark-cli", + SilenceErrors: true, + SilenceUsage: true, + } +} + func TestApiCmd_FlagParsing(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return nil }) @@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"}) err := cmd.Execute() if err != nil { @@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"}) if err := cmd.Execute(); err != nil { t.Fatalf("--params null with --page-size should not error, got: %v", err) @@ -98,7 +113,7 @@ func TestApiCmd_BotMode(t *testing.T) { Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}}, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"}) err := cmd.Execute() if err != nil { @@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET"}) // missing path err := cmd.Execute() if err == nil { @@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"}) err := cmd.Execute() if err == nil { @@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) fn := cmd.ValidArgsFunction tests := []struct { @@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) flag := cmd.Flags().Lookup("as") if flag == nil { t.Fatal("expected --as flag to be registered") @@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) { }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return nil }) @@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"}) err := cmd.Execute() if err == nil { @@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) { }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return apiRun(opts) }) @@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) { ContentType: "application/octet-stream", }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"}) err := cmd.Execute() if err != nil { @@ -328,7 +343,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"}) err := cmd.Execute() if err != nil { @@ -368,7 +383,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"}) err := cmd.Execute() // Should return an error @@ -409,7 +424,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) err := cmd.Execute() if err != nil { @@ -448,7 +463,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) err := cmd.Execute() if err == nil { @@ -483,7 +498,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"}) if err := cmd.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) @@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) { }, }) - root := &cobra.Command{Use: "lark-cli"} - root.AddCommand(NewCmdApi(f, nil)) + root := newTestRootCmd() + root.AddCommand(newTestApiCmd(f, nil)) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"}) if err := root.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) @@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) { }, }) - root := &cobra.Command{Use: "lark-cli"} - root.AddCommand(NewCmdApi(f, nil)) + root := newTestRootCmd() + root.AddCommand(newTestApiCmd(f, nil)) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) if err := root.Execute(); err != nil { t.Fatalf("unexpected error: %v", err) @@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) { }, }) - root := &cobra.Command{Use: "lark-cli"} - root.AddCommand(NewCmdApi(f, nil)) + root := newTestRootCmd() + root.AddCommand(newTestApiCmd(f, nil)) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) err := root.Execute() if err == nil { @@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) { }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return nil }) @@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) { }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return nil }) @@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) }) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"}) @@ -791,7 +806,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"}) err := cmd.Execute() if err != nil { @@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) }) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"}) @@ -830,7 +845,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) }) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["}) @@ -859,7 +874,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"}) err := cmd.Execute() if err != nil { @@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) { }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return nil }) @@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) { AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return nil }) @@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) }) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"}) @@ -934,7 +949,7 @@ func TestApiCmd_FileWithGET(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) }) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"}) @@ -951,7 +966,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) { f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { return apiRun(opts) }) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"}) @@ -974,7 +989,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) { f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"}) err := cmd.Execute() if err != nil { @@ -1015,7 +1030,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) { }, }) - cmd := NewCmdApi(f, nil) + cmd := newTestApiCmd(f, nil) cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"}) err := cmd.Execute() if err == nil { @@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) { }) var gotOpts *APIOptions - cmd := NewCmdApi(f, func(opts *APIOptions) error { + cmd := newTestApiCmd(f, func(opts *APIOptions) error { gotOpts = opts return nil }) diff --git a/shortcuts/doc/docs_history.go b/shortcuts/doc/docs_history.go new file mode 100644 index 000000000..660dec42a --- /dev/null +++ b/shortcuts/doc/docs_history.go @@ -0,0 +1,258 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "context" + "fmt" + "net/http" + "strconv" + "strings" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/validate" + "github.com/larksuite/cli/shortcuts/common" +) + +type docsHistoryListSpec struct { + Doc documentRef + PageSize int + PageToken string +} + +type docsHistoryRevertSpec struct { + Doc documentRef + HistoryVersionID string + WaitTimeoutMs int +} + +type docsHistoryRevertStatusSpec struct { + Doc documentRef + TaskID string +} + +func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) { + ref, err := parseDocumentRef(raw) + if err != nil { + return documentRef{}, err + } + if ref.Kind == "doc" { + return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc") + } + return ref, nil +} + +func validateDocsHistoryPageSize(pageSize int) error { + if pageSize < 1 || pageSize > 20 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size") + } + return nil +} + +func validateDocsHistoryVersionID(historyVersionID string) error { + version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64) + if err != nil || version <= 0 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id") + } + return nil +} + +func validateDocsHistoryWaitTimeout(timeoutMs int) error { + if timeoutMs < 0 || timeoutMs > 30000 { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms") + } + return nil +} + +func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} { + params := map[string]interface{}{ + "page_size": spec.PageSize, + } + if spec.PageToken != "" { + params["page_token"] = spec.PageToken + } + return params +} + +func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} { + return map[string]interface{}{ + "history_version_id": spec.HistoryVersionID, + "wait_timeout_ms": spec.WaitTimeoutMs, + } +} + +func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} { + return map[string]interface{}{ + "task_id": spec.TaskID, + } +} + +func docsHistoryAPIPath(docToken, suffix string) string { + return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix) +} + +var DocsHistoryList = common.Shortcut{ + Service: "docs", + Command: "+history-list", + Description: "List Lark document history versions", + Risk: "read", + Scopes: []string{"docx:document:readonly"}, + AuthTypes: []string{"user", "bot"}, + PostMount: installDocsShortcutHelp("+history-list"), + Flags: []common.Flag{ + {Name: "doc", Desc: "document URL or token", Required: true}, + {Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"}, + {Name: "page-token", Desc: "pagination token from the previous page's page_token"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil { + return err + } + return validateDocsHistoryPageSize(runtime.Int("page-size")) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list") + spec := docsHistoryListSpec{ + Doc: ref, + PageSize: runtime.Int("page-size"), + PageToken: strings.TrimSpace(runtime.Str("page-token")), + } + return common.NewDryRunAPI(). + Desc("OpenAPI: list document history versions"). + GET("/open-apis/docs_ai/v1/documents/:document_id/histories"). + Set("document_id", spec.Doc.Token). + Params(docsHistoryListParams(spec)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list") + spec := docsHistoryListSpec{ + Doc: ref, + PageSize: runtime.Int("page-size"), + PageToken: strings.TrimSpace(runtime.Str("page-token")), + } + + data, err := runtime.CallAPITyped( + http.MethodGet, + docsHistoryAPIPath(spec.Doc.Token, "histories"), + docsHistoryListParams(spec), + nil, + ) + if err != nil { + return err + } + runtime.OutRaw(data, nil) + return nil + }, +} + +var DocsHistoryRevert = common.Shortcut{ + Service: "docs", + Command: "+history-revert", + Description: "Revert a Lark document to a historical version", + Risk: "write", + Scopes: []string{"docx:document:write_only", "docx:document:readonly"}, + AuthTypes: []string{"user", "bot"}, + PostMount: installDocsShortcutHelp("+history-revert"), + Flags: []common.Flag{ + {Name: "doc", Desc: "document URL or token", Required: true}, + {Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true}, + {Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil { + return err + } + if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil { + return err + } + return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms")) + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert") + spec := docsHistoryRevertSpec{ + Doc: ref, + HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")), + WaitTimeoutMs: runtime.Int("wait-timeout-ms"), + } + return common.NewDryRunAPI(). + Desc("OpenAPI: revert document history"). + POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert"). + Set("document_id", spec.Doc.Token). + Body(docsHistoryRevertBody(spec)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert") + spec := docsHistoryRevertSpec{ + Doc: ref, + HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")), + WaitTimeoutMs: runtime.Int("wait-timeout-ms"), + } + + data, err := runtime.CallAPITyped( + http.MethodPost, + docsHistoryAPIPath(spec.Doc.Token, "history/revert"), + nil, + docsHistoryRevertBody(spec), + ) + if err != nil { + return err + } + runtime.OutRaw(data, nil) + return nil + }, +} + +var DocsHistoryRevertStatus = common.Shortcut{ + Service: "docs", + Command: "+history-revert-status", + Description: "Get Lark document history revert task status", + Risk: "read", + Scopes: []string{"docx:document:readonly"}, + AuthTypes: []string{"user", "bot"}, + PostMount: installDocsShortcutHelp("+history-revert-status"), + Flags: []common.Flag{ + {Name: "doc", Desc: "document URL or token", Required: true}, + {Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true}, + }, + Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { + if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil { + return err + } + if strings.TrimSpace(runtime.Str("task-id")) == "" { + return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id") + } + return nil + }, + DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { + ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status") + spec := docsHistoryRevertStatusSpec{ + Doc: ref, + TaskID: strings.TrimSpace(runtime.Str("task-id")), + } + return common.NewDryRunAPI(). + Desc("OpenAPI: get document history revert status"). + GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status"). + Set("document_id", spec.Doc.Token). + Params(docsHistoryStatusParams(spec)) + }, + Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { + ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status") + spec := docsHistoryRevertStatusSpec{ + Doc: ref, + TaskID: strings.TrimSpace(runtime.Str("task-id")), + } + + data, err := runtime.CallAPITyped( + http.MethodGet, + docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"), + docsHistoryStatusParams(spec), + nil, + ) + if err != nil { + return err + } + runtime.OutRaw(data, nil) + return nil + }, +} diff --git a/shortcuts/doc/docs_history_test.go b/shortcuts/doc/docs_history_test.go new file mode 100644 index 000000000..28431fe63 --- /dev/null +++ b/shortcuts/doc/docs_history_test.go @@ -0,0 +1,309 @@ +// Copyright (c) 2026 Lark Technologies Pte. Ltd. +// SPDX-License-Identifier: MIT + +package doc + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "testing" + + "github.com/larksuite/cli/errs" + "github.com/larksuite/cli/internal/cmdutil" + "github.com/larksuite/cli/internal/httpmock" + "github.com/larksuite/cli/shortcuts/common" + "github.com/spf13/cobra" +) + +func TestDocsHistoryValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + shortcut common.Shortcut + args []string + param string + }{ + { + name: "list rejects legacy doc URL", + shortcut: DocsHistoryList, + args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"}, + param: "--doc", + }, + { + name: "list rejects invalid page size", + shortcut: DocsHistoryList, + args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"}, + param: "--page-size", + }, + { + name: "revert rejects invalid history version id", + shortcut: DocsHistoryRevert, + args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"}, + param: "--history-version-id", + }, + { + name: "revert rejects invalid wait timeout", + shortcut: DocsHistoryRevert, + args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"}, + param: "--wait-timeout-ms", + }, + { + name: "status rejects empty task id", + shortcut: DocsHistoryRevertStatus, + args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"}, + param: "--task-id", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation")) + err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout) + if err == nil { + t.Fatal("expected validation error, got nil") + } + _, ok := errs.ProblemOf(err) + if !ok { + t.Fatalf("error is not typed: %T %v", err, err) + } + var validationErr *errs.ValidationError + if !errors.As(err, &validationErr) { + t.Fatalf("expected validation error, got %T: %v", err, err) + } + if validationErr.Param != tt.param { + t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err) + } + }) + } +} + +func TestDocsHistoryDryRun(t *testing.T) { + t.Parallel() + + listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{ + "doc": "doxcnHistoryDryRun", + "page-size": "5", + "page-token": "page_token_1", + }) + listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil))) + if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want { + t.Fatalf("list dry-run URL = %q, want %q", got, want) + } + if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 { + t.Fatalf("list page_size = %d, want 5", got) + } + if got := listDry.API[0].Params["page_token"]; got != "page_token_1" { + t.Fatalf("list page_token = %#v, want page_token_1", got) + } + + revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{ + "doc": "doxcnHistoryDryRun", + "history-version-id": "42", + "wait-timeout-ms": "30000", + }) + revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil))) + if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want { + t.Fatalf("revert dry-run URL = %q, want %q", got, want) + } + if got := revertDry.API[0].Body["history_version_id"]; got != "42" { + t.Fatalf("revert history_version_id = %#v, want 42", got) + } + if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 { + t.Fatalf("revert wait_timeout_ms = %d, want 30000", got) + } + + statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{ + "doc": "doxcnHistoryDryRun", + "task-id": "task_1", + }) + statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil))) + if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want { + t.Fatalf("status dry-run URL = %q, want %q", got, want) + } + if got := statusDry.API[0].Params["task_id"]; got != "task_1" { + t.Fatalf("status task_id = %#v, want task_1", got) + } +} + +func TestDocsHistoryExecuteList(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list")) + stub := &httpmock.Stub{ + Method: "GET", + URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "entries": []interface{}{ + map[string]interface{}{ + "revision_id": float64(42), + "history_version_id": "11", + "edit_time": "1780000000", + "type": float64(1), + "editor_ids": []interface{}{"ou_1"}, + }, + }, + "has_more": true, + "page_token": "page_token_2", + }, + }, + } + reg.Register(stub) + + err := mountAndRunDocs(t, DocsHistoryList, []string{ + "+history-list", + "--doc", "doxcnHistory", + "--page-size", "5", + "--page-token", "page_token_1", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDocsHistoryEnvelope(t, stdout) + if got := data["page_token"]; got != "page_token_2" { + t.Fatalf("page_token = %#v, want page_token_2", got) + } + entries, _ := data["entries"].([]interface{}) + if len(entries) != 1 { + t.Fatalf("entries = %#v, want one entry", data["entries"]) + } +} + +func TestDocsHistoryExecuteRevert(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert")) + stub := &httpmock.Stub{ + Method: "POST", + URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "task_id": "task_1", + "status": "running", + "history_version_id": "42", + "poll_after_ms": float64(10000), + }, + }, + } + reg.Register(stub) + + err := mountAndRunDocs(t, DocsHistoryRevert, []string{ + "+history-revert", + "--doc", "doxcnHistory", + "--history-version-id", "42", + "--wait-timeout-ms", "0", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var body map[string]interface{} + if err := json.Unmarshal(stub.CapturedBody, &body); err != nil { + t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody) + } + if got := body["history_version_id"]; got != "42" { + t.Fatalf("history_version_id = %#v, want 42", got) + } + if got := int(body["wait_timeout_ms"].(float64)); got != 0 { + t.Fatalf("wait_timeout_ms = %d, want 0", got) + } + + data := decodeDocsHistoryEnvelope(t, stdout) + if got := data["task_id"]; got != "task_1" { + t.Fatalf("task_id = %#v, want task_1", got) + } +} + +func TestDocsHistoryExecuteRevertStatus(t *testing.T) { + f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status")) + reg.Register(&httpmock.Stub{ + Method: "GET", + URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status", + Body: map[string]interface{}{ + "code": 0, + "msg": "ok", + "data": map[string]interface{}{ + "status": "partial_failed", + "history_version_id": "11", + "failed_block_tokens": []interface{}{"blk_1"}, + }, + }, + }) + + err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{ + "+history-revert-status", + "--doc", "doxcnHistory", + "--task-id", "task_1", + "--as", "bot", + }, f, stdout) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + data := decodeDocsHistoryEnvelope(t, stdout) + if got := data["status"]; got != "partial_failed" { + t.Fatalf("status = %#v, want partial_failed", got) + } + if got := data["history_version_id"]; got != "11" { + t.Fatalf("history_version_id = %#v, want 11", got) + } + failed, _ := data["failed_block_tokens"].([]interface{}) + if len(failed) != 1 || failed[0] != "blk_1" { + t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"]) + } +} + +func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command { + t.Helper() + + cmd := &cobra.Command{Use: shortcut.Command} + for _, flag := range shortcut.Flags { + switch flag.Type { + case "int": + cmd.Flags().Int(flag.Name, 0, flag.Desc) + default: + cmd.Flags().String(flag.Name, flag.Default, flag.Desc) + } + } + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + return cmd +} + +func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} { + t.Helper() + + var envelope map[string]interface{} + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String()) + } + data, _ := envelope["data"].(map[string]interface{}) + if data == nil { + t.Fatalf("missing data in envelope: %#v", envelope) + } + return data +} + +func TestDocsHistoryURLValidationMessage(t *testing.T) { + t.Parallel() + + _, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list") + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "only supports docx documents") { + t.Fatalf("unexpected error: %v", err) + } +} diff --git a/shortcuts/doc/shortcuts.go b/shortcuts/doc/shortcuts.go index a4e9781f6..25c4e61c9 100644 --- a/shortcuts/doc/shortcuts.go +++ b/shortcuts/doc/shortcuts.go @@ -31,6 +31,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string { return docsSkillReadCommand + " references/lark-doc-fetch.md" case "update": return docsSkillReadCommand + " references/lark-doc-update.md" + case "history-list", "history-revert", "history-revert-status": + return docsSkillReadCommand + " references/lark-doc-history.md" default: return docsSkillReadCommand } @@ -44,6 +46,12 @@ func docsHelpCommandForShortcut(shortcut string) string { return "lark-cli docs +fetch --help" case "update": return "lark-cli docs +update --help" + case "history-list": + return "lark-cli docs +history-list --help" + case "history-revert": + return "lark-cli docs +history-revert --help" + case "history-revert-status": + return "lark-cli docs +history-revert-status --help" default: return "lark-cli docs --help" } @@ -56,6 +64,9 @@ func Shortcuts() []common.Shortcut { DocsCreate, DocsFetch, DocsUpdate, + DocsHistoryList, + DocsHistoryRevert, + DocsHistoryRevertStatus, DocMediaInsert, DocMediaUpload, DocMediaPreview, diff --git a/shortcuts/register_test.go b/shortcuts/register_test.go index 5137584b9..c9514b0cb 100644 --- a/shortcuts/register_test.go +++ b/shortcuts/register_test.go @@ -170,6 +170,27 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) { } } +func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) { + program := &cobra.Command{Use: "root"} + RegisterShortcuts(program, newRegisterTestFactory(t)) + + for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} { + cmd, _, err := program.Find([]string{"docs", name}) + if err != nil { + t.Fatalf("find docs %s shortcut: %v", name, err) + } + if cmd == nil || cmd.Name() != name { + t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd) + } + if cmd.Flags().Lookup("api-version") != nil { + t.Fatalf("docs %s should not expose --api-version", name) + } + if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") { + t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long) + } + } +} + func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) { program := &cobra.Command{Use: "root"} RegisterShortcuts(program, newRegisterTestFactory(t)) diff --git a/skills/lark-doc/SKILL.md b/skills/lark-doc/SKILL.md index de8035122..ae0159668 100644 --- a/skills/lark-doc/SKILL.md +++ b/skills/lark-doc/SKILL.md @@ -5,7 +5,7 @@ description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文 metadata: requires: bins: ["lark-cli"] - cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help" + cliHelp: "lark-cli docs --help; lark-cli docs +create --help; lark-cli docs +fetch --help; lark-cli docs +update --help; lark-cli docs +history-list --help; lark-cli docs +history-revert --help; lark-cli docs +history-revert-status --help; lark-cli docs +resource-download --help; lark-cli docs +resource-update --help; lark-cli docs +resource-delete --help" --- # docs @@ -17,6 +17,7 @@ metadata: lark-cli docs +fetch --doc "文档URL或token" lark-cli docs +create --content '
内容
' lark-cli docs +update --doc "文档URL或token" --command append --content '内容
' +lark-cli docs +history-list --doc "文档URL或token" ``` ## 前置条件 — 执行操作前必读 @@ -25,6 +26,7 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`
- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 ` hello hello hello