mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
Compare commits
13 Commits
feat/lark-
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0b6f35fee | ||
|
|
91d785f92f | ||
|
|
e621c6e50f | ||
|
|
869a259d4e | ||
|
|
ee46e22abd | ||
|
|
b76dc18c2f | ||
|
|
85679d4258 | ||
|
|
1ba4f3973c | ||
|
|
c45ff569c4 | ||
|
|
a1506cdffb | ||
|
|
3595356ea1 | ||
|
|
73be1d06ec | ||
|
|
cccf025599 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,6 +27,9 @@ Thumbs.db
|
|||||||
# Go
|
# Go
|
||||||
docs/ref
|
docs/ref
|
||||||
docs/
|
docs/
|
||||||
|
!tests/cli_e2e/docs/
|
||||||
|
!tests/cli_e2e/docs/*.go
|
||||||
|
!tests/cli_e2e/docs/*.md
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -2,6 +2,22 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [v1.0.65] - 2026-07-03
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **drive**: Document 30-char query limit for `+search` (#1560)
|
||||||
|
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
|
||||||
|
- **doc**: Sync lark-doc skill content from online-doc (#1701)
|
||||||
|
|
||||||
## [v1.0.64] - 2026-07-02
|
## [v1.0.64] - 2026-07-02
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
@@ -1355,6 +1371,7 @@ Bundled AI agent skills for intelligent assistance:
|
|||||||
- Bilingual documentation (English & Chinese).
|
- Bilingual documentation (English & Chinese).
|
||||||
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
|
||||||
|
|
||||||
|
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
|
||||||
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
|
||||||
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
|
||||||
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
|
||||||
|
|||||||
18
README.md
18
README.md
@@ -233,6 +233,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
|||||||
--format csv # Comma-separated values
|
--format csv # Comma-separated values
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### JSON Output Contract
|
||||||
|
|
||||||
|
With `--format json` (the default), success and error envelopes are distinct.
|
||||||
|
|
||||||
|
Success goes to **stdout**, exit code `0`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors go to **stderr**, non-zero exit code:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
|
||||||
|
|
||||||
### Pagination
|
### Pagination
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
18
README.zh.md
18
README.zh.md
@@ -234,6 +234,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
|
|||||||
--format csv # 逗号分隔值
|
--format csv # 逗号分隔值
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### JSON 输出契约
|
||||||
|
|
||||||
|
`--format json`(默认)下,成功与错误的信封结构不同。
|
||||||
|
|
||||||
|
成功信封写入 **stdout**,退出码 0:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
错误信封写入 **stderr**,退出码非 0:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code` 和 `msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
|
||||||
|
|
||||||
### 分页
|
### 分页
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -20,13 +20,28 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"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) {
|
func TestApiCmd_FlagParsing(t *testing.T) {
|
||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("--params null with --page-size should not error, got: %v", err)
|
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"}},
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
cmd.SetArgs([]string{"GET"}) // missing path
|
cmd.SetArgs([]string{"GET"}) // missing path
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
fn := cmd.ValidArgsFunction
|
fn := cmd.ValidArgsFunction
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
|
||||||
})
|
})
|
||||||
|
|
||||||
cmd := NewCmdApi(f, nil)
|
cmd := newTestApiCmd(f, nil)
|
||||||
flag := cmd.Flags().Lookup("as")
|
flag := cmd.Flags().Lookup("as")
|
||||||
if flag == nil {
|
if flag == nil {
|
||||||
t.Fatal("expected --as flag to be registered")
|
t.Fatal("expected --as flag to be registered")
|
||||||
@@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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", "-"})
|
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return apiRun(opts)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
@@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
|
|||||||
ContentType: "application/octet-stream",
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
// Should return an error
|
// 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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||||
if err := cmd.Execute(); err != nil {
|
if err := cmd.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
if err := root.Execute(); err != nil {
|
if err := root.Execute(); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
root := &cobra.Command{Use: "lark-cli"}
|
root := newTestRootCmd()
|
||||||
root.AddCommand(NewCmdApi(f, nil))
|
root.AddCommand(newTestApiCmd(f, nil))
|
||||||
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
|
||||||
err := root.Execute()
|
err := root.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
|
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,
|
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)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
|
|||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
||||||
})
|
})
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
@@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
|
|||||||
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
|
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{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
|
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{
|
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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)
|
return apiRun(opts)
|
||||||
})
|
})
|
||||||
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
|
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{
|
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
|
||||||
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
|
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"})
|
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err != nil {
|
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"})
|
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
|
||||||
err := cmd.Execute()
|
err := cmd.Execute()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var gotOpts *APIOptions
|
var gotOpts *APIOptions
|
||||||
cmd := NewCmdApi(f, func(opts *APIOptions) error {
|
cmd := newTestApiCmd(f, func(opts *APIOptions) error {
|
||||||
gotOpts = opts
|
gotOpts = opts
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// completeSchemaPath is a thin adapter over the embedded catalog's Complete.
|
// completeSchemaPath is a thin adapter over the schema catalog's Complete.
|
||||||
// It uses the embedded source so completion candidates match what `schema`
|
// It uses the same source as schema execution so completion candidates match
|
||||||
// execution can resolve (both overlay-free).
|
// what `schema` can resolve.
|
||||||
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
|
||||||
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
|
||||||
mode := f.ResolveStrictMode(cmd.Context())
|
mode := f.ResolveStrictMode(cmd.Context())
|
||||||
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
|
||||||
directive := cobra.ShellCompDirectiveNoFileComp
|
directive := cobra.ShellCompDirectiveNoFileComp
|
||||||
if noSpace {
|
if noSpace {
|
||||||
directive |= cobra.ShellCompDirectiveNoSpace
|
directive |= cobra.ShellCompDirectiveNoSpace
|
||||||
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
|
|||||||
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runSchema resolves the path through the embedded catalog and renders the
|
// runSchema resolves the path through the schema catalog and renders the
|
||||||
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
|
||||||
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the
|
||||||
// output shape — a single resolved method renders as one envelope object,
|
// output shape — a single resolved method renders as one envelope object,
|
||||||
// anything broader as an array — and maps resolve failures to hints.
|
// anything broader as an array — and maps resolve failures to hints.
|
||||||
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
|
||||||
catalog := registry.EmbeddedCatalog()
|
catalog := registry.SchemaCatalog()
|
||||||
|
if len(catalog.Services()) == 0 {
|
||||||
|
// No embedded metadata and the runtime fallback is empty too: offline
|
||||||
|
// with a cold cache, remote meta off, or an unwritable cache dir.
|
||||||
|
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
|
||||||
|
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
|
||||||
|
}
|
||||||
target, err := catalog.Resolve(parts)
|
target, err := catalog.Resolve(parts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return resolveError(err)
|
return resolveError(err)
|
||||||
|
|||||||
@@ -102,7 +102,8 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
|||||||
Long: `Update lark-cli to the latest version.
|
Long: `Update lark-cli to the latest version.
|
||||||
|
|
||||||
Detects the installation method automatically:
|
Detects the installation method automatically:
|
||||||
- npm install: runs npm install -g @larksuite/cli@<version>
|
- npm install: runs npm install -g @larksuite/cli@<version>
|
||||||
|
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
|
||||||
- manual/other: shows GitHub Releases download URL
|
- manual/other: shows GitHub Releases download URL
|
||||||
|
|
||||||
Use --json for structured output (for AI agents and scripts).
|
Use --json for structured output (for AI agents and scripts).
|
||||||
@@ -164,7 +165,7 @@ func updateRun(opts *UpdateOptions) error {
|
|||||||
if !detect.CanAutoUpdate() {
|
if !detect.CanAutoUpdate() {
|
||||||
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
return doManualUpdate(opts, io, cur, latest, detect, updater)
|
||||||
}
|
}
|
||||||
return doNpmUpdate(opts, io, cur, latest, updater)
|
return doAutoUpdate(opts, io, cur, latest, detect, updater)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Output helpers ---
|
// --- Output helpers ---
|
||||||
@@ -226,12 +227,23 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
|
|||||||
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
|
||||||
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
|
||||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||||
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
if detect.Method == selfupdate.InstallPnpm {
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
|
||||||
|
}
|
||||||
emitSkillsTextHints(io, skillsResult)
|
emitSkillsTextHints(io, skillsResult)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error {
|
func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
|
||||||
|
pm := "npm"
|
||||||
|
install := updater.RunNpmInstall
|
||||||
|
if detect.Method == selfupdate.InstallPnpm {
|
||||||
|
pm = "pnpm"
|
||||||
|
install = updater.RunPnpmInstall
|
||||||
|
}
|
||||||
|
|
||||||
restore, err := updater.PrepareSelfReplace()
|
restore, err := updater.PrepareSelfReplace()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return reportError(opts, io, "update_error",
|
return reportError(opts, io, "update_error",
|
||||||
@@ -239,19 +251,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !opts.JSON {
|
if !opts.JSON {
|
||||||
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest)
|
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
|
||||||
}
|
}
|
||||||
|
|
||||||
npmResult := updater.RunNpmInstall(latest)
|
npmResult := install(latest)
|
||||||
if npmResult.Err != nil {
|
if npmResult.Err != nil {
|
||||||
restore()
|
restore()
|
||||||
combined := npmResult.CombinedOutput()
|
combined := npmResult.CombinedOutput()
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
output.PrintJson(io.Out, map[string]interface{}{
|
output.PrintJson(io.Out, map[string]interface{}{
|
||||||
"ok": false, "error": map[string]interface{}{
|
"ok": false, "error": map[string]interface{}{
|
||||||
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err),
|
"type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
|
||||||
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
"detail": selfupdate.Truncate(combined, maxNpmOutput),
|
||||||
"hint": permissionHint(combined),
|
"hint": permissionHint(combined, pm),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
@@ -263,7 +275,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
|
||||||
}
|
}
|
||||||
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
|
||||||
if hint := permissionHint(combined); hint != "" {
|
if hint := permissionHint(combined, pm); hint != "" {
|
||||||
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
fmt.Fprintf(io.ErrOut, " %s\n", hint)
|
||||||
}
|
}
|
||||||
return output.ErrBare(output.ExitAPI)
|
return output.ErrBare(output.ExitAPI)
|
||||||
@@ -274,7 +286,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
if err := updater.VerifyBinary(latest); err != nil {
|
if err := updater.VerifyBinary(latest); err != nil {
|
||||||
restore()
|
restore()
|
||||||
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
msg := fmt.Sprintf("new binary verification failed: %s", err)
|
||||||
hint := verificationFailureHint(updater, latest)
|
hint := verificationFailureHint(updater, latest, pm)
|
||||||
if opts.JSON {
|
if opts.JSON {
|
||||||
output.PrintJson(io.Out, map[string]interface{}{
|
output.PrintJson(io.Out, map[string]interface{}{
|
||||||
"ok": false,
|
"ok": false,
|
||||||
@@ -304,23 +316,33 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
|
|||||||
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
|
||||||
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
|
||||||
if skillsResult != nil {
|
if skillsResult != nil {
|
||||||
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n")
|
skillsPM := "npx"
|
||||||
|
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
|
||||||
|
skillsPM = "pnpm dlx"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
|
||||||
}
|
}
|
||||||
emitSkillsTextHints(io, skillsResult)
|
emitSkillsTextHints(io, skillsResult)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func permissionHint(npmOutput string) string {
|
func permissionHint(pmOutput, pm string) string {
|
||||||
if strings.Contains(npmOutput, "EACCES") && !isWindows() {
|
if !strings.Contains(pmOutput, "EACCES") || isWindows() {
|
||||||
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
return ""
|
||||||
}
|
}
|
||||||
return ""
|
if pm == "pnpm" {
|
||||||
|
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
|
||||||
|
}
|
||||||
|
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
|
||||||
}
|
}
|
||||||
|
|
||||||
func verificationFailureHint(updater *selfupdate.Updater, latest string) string {
|
func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
|
||||||
if updater.CanRestorePreviousVersion() {
|
if updater.CanRestorePreviousVersion() {
|
||||||
return "the previous version has been restored"
|
return "the previous version has been restored"
|
||||||
}
|
}
|
||||||
|
if pm == "pnpm" {
|
||||||
|
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||||
|
}
|
||||||
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
|
|||||||
t.Cleanup(func() { newUpdater = origNew })
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
|
||||||
|
// and fails the test if the npm install path is invoked.
|
||||||
|
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
|
||||||
|
t.Helper()
|
||||||
|
origNew := newUpdater
|
||||||
|
newUpdater = func() *selfupdate.Updater {
|
||||||
|
u := selfupdate.New()
|
||||||
|
u.DetectOverride = func() selfupdate.DetectResult { return result }
|
||||||
|
u.PnpmInstallOverride = pnpmFn
|
||||||
|
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
|
||||||
|
t.Errorf("npm install must not be called for a pnpm install")
|
||||||
|
return &selfupdate.NpmResult{}
|
||||||
|
}
|
||||||
|
u.VerifyOverride = func(string) error { return nil }
|
||||||
|
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
|
||||||
|
u.SkillsCommandOverride = successfulSkillsCommand()
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { newUpdater = origNew })
|
||||||
|
}
|
||||||
|
|
||||||
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
|
||||||
return func() *selfupdate.NpmResult {
|
return func() *selfupdate.NpmResult {
|
||||||
r := &selfupdate.NpmResult{}
|
r := &selfupdate.NpmResult{}
|
||||||
@@ -81,6 +102,110 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_JSON(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, stdout, _ := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
)
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
|
||||||
|
t.Errorf("expected updated in output, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_Human(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, stderr := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
|
||||||
|
)
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
out := stderr.String()
|
||||||
|
if !strings.Contains(out, "via pnpm") {
|
||||||
|
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
|
||||||
|
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "Successfully updated") {
|
||||||
|
t.Errorf("expected success message, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, stdout, _ := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{"--json"})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetectAndPnpm(t,
|
||||||
|
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
|
||||||
|
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
|
||||||
|
)
|
||||||
|
err := cmd.Execute()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error exit")
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
|
||||||
|
t.Errorf("expected failure envelope, got: %s", out)
|
||||||
|
}
|
||||||
|
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
|
||||||
|
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
f, _, stderr := newTestFactory(t)
|
||||||
|
cmd := NewCmdUpdate(f)
|
||||||
|
cmd.SetArgs([]string{})
|
||||||
|
origFetch := fetchLatest
|
||||||
|
fetchLatest = func() (string, error) { return "2.0.0", nil }
|
||||||
|
defer func() { fetchLatest = origFetch }()
|
||||||
|
origVersion := currentVersion
|
||||||
|
currentVersion = func() string { return "1.0.0" }
|
||||||
|
defer func() { currentVersion = origVersion }()
|
||||||
|
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
|
||||||
|
if err := cmd.Execute(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
out := stderr.String()
|
||||||
|
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
|
||||||
|
t.Errorf("expected pnpm manual reason, got: %s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "pnpm add -g") {
|
||||||
|
t.Errorf("expected pnpm add -g hint, got: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeVersion(t *testing.T) {
|
func TestNormalizeVersion(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
input string
|
input string
|
||||||
@@ -266,6 +391,9 @@ func TestUpdateNpm_Human(t *testing.T) {
|
|||||||
if !strings.Contains(out, "Successfully updated") {
|
if !strings.Contains(out, "Successfully updated") {
|
||||||
t.Errorf("expected success message in stderr, got: %s", out)
|
t.Errorf("expected success message in stderr, got: %s", out)
|
||||||
}
|
}
|
||||||
|
if !strings.Contains(out, "Updating skills via npx ...") {
|
||||||
|
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpdateForce_JSON(t *testing.T) {
|
func TestUpdateForce_JSON(t *testing.T) {
|
||||||
@@ -739,9 +867,9 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
origOS := currentOS
|
origOS := currentOS
|
||||||
defer func() { currentOS = origOS }()
|
defer func() { currentOS = origOS }()
|
||||||
|
|
||||||
// Linux: EACCES should produce a hint with npm prefix guidance.
|
// Linux + npm: EACCES should produce a hint with npm prefix guidance.
|
||||||
currentOS = "linux"
|
currentOS = "linux"
|
||||||
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'")
|
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
|
||||||
if !strings.Contains(hint, "npm global prefix") {
|
if !strings.Contains(hint, "npm global prefix") {
|
||||||
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
t.Errorf("expected npm prefix hint on linux, got: %s", hint)
|
||||||
}
|
}
|
||||||
@@ -749,16 +877,25 @@ func TestPermissionHint(t *testing.T) {
|
|||||||
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
|
||||||
|
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
|
||||||
|
if !strings.Contains(pnpmHint, "pnpm setup") {
|
||||||
|
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
|
||||||
|
}
|
||||||
|
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
|
||||||
|
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
|
||||||
|
}
|
||||||
|
|
||||||
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
// Windows: EACCES hint is suppressed (no EACCES on Windows).
|
||||||
currentOS = "windows"
|
currentOS = "windows"
|
||||||
hint = permissionHint("EACCES: permission denied")
|
hint = permissionHint("EACCES: permission denied", "npm")
|
||||||
if hint != "" {
|
if hint != "" {
|
||||||
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
t.Errorf("expected empty hint on Windows, got: %s", hint)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Non-EACCES error: always empty.
|
// Non-EACCES error: always empty.
|
||||||
currentOS = "linux"
|
currentOS = "linux"
|
||||||
if got := permissionHint("some other error"); got != "" {
|
if got := permissionHint("some other error", "npm"); got != "" {
|
||||||
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
t.Errorf("expected empty hint for non-EACCES, got: %s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,28 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
|
|||||||
`challenge_required` / `access_denied`, and process exit is `6` via
|
`challenge_required` / `access_denied`, and process exit is `6` via
|
||||||
`CategoryPolicy`.
|
`CategoryPolicy`.
|
||||||
|
|
||||||
|
### Success envelope (stdout)
|
||||||
|
|
||||||
|
For contrast: success responses render to **stdout** as an
|
||||||
|
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"identity": "user",
|
||||||
|
"data": { "guid": "e297d3d0-..." },
|
||||||
|
"meta": { "count": 1 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Consumers must branch on `ok` (or the process exit code). The success
|
||||||
|
envelope has **no top-level `code` or `msg` field** — `code` exists only
|
||||||
|
inside `error`, where it is the upstream numeric code (invariant 4).
|
||||||
|
Wrappers that follow the raw OpenAPI convention and test `code == 0`
|
||||||
|
will misclassify every successful call as a failure, which is
|
||||||
|
especially dangerous around write commands (e.g. retrying a create that
|
||||||
|
already succeeded).
|
||||||
|
|
||||||
## Categories
|
## Categories
|
||||||
|
|
||||||
| Category | When | Exit | Typed struct |
|
| Category | When | Exit | Typed struct |
|
||||||
|
|||||||
@@ -77,14 +77,10 @@ func loadService(service string) map[string]json.RawMessage {
|
|||||||
// space→dot fallback covers domains where the two already coincide.
|
// space→dot fallback covers domains where the two already coincide.
|
||||||
func commandFormResolver(service string) func(string) string {
|
func commandFormResolver(service string) func(string) string {
|
||||||
byForm := map[string]string{}
|
byForm := map[string]string{}
|
||||||
for _, svc := range registry.EmbeddedServicesTyped() {
|
if svc, ok := registry.SchemaCatalog().Service(service); ok {
|
||||||
if svc.Name != service {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
for _, ref := range apicatalog.ServiceMethods(svc, nil) {
|
||||||
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
|
||||||
}
|
}
|
||||||
break
|
|
||||||
}
|
}
|
||||||
return func(h string) string {
|
return func(h string) string {
|
||||||
h = strings.TrimSpace(h)
|
h = strings.TrimSpace(h)
|
||||||
|
|||||||
@@ -6,12 +6,10 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
|
||||||
"reflect"
|
"reflect"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"unicode"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/credential"
|
"github.com/larksuite/cli/extension/credential"
|
||||||
"github.com/larksuite/cli/extension/fileio"
|
"github.com/larksuite/cli/extension/fileio"
|
||||||
@@ -40,8 +38,6 @@ const (
|
|||||||
BuildKindUnknown = "unknown"
|
BuildKindUnknown = "unknown"
|
||||||
|
|
||||||
officialModulePath = "github.com/larksuite/cli"
|
officialModulePath = "github.com/larksuite/cli"
|
||||||
|
|
||||||
agentTraceMaxLen = 1024
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
// UserAgentValue returns the User-Agent value: "lark-cli/{version}".
|
||||||
@@ -49,25 +45,6 @@ func UserAgentValue() string {
|
|||||||
return SourceValue + "/" + build.Version
|
return SourceValue + "/" + build.Version
|
||||||
}
|
}
|
||||||
|
|
||||||
// AgentTraceValue returns a header-safe value from the
|
|
||||||
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
|
|
||||||
// surrounding whitespace, rejects values containing any Unicode
|
|
||||||
// control character or exceeding agentTraceMaxLen, and returns ""
|
|
||||||
// for any invalid or empty value. Callers can use the result
|
|
||||||
// directly in HTTP headers without further sanitisation.
|
|
||||||
func AgentTraceValue() string {
|
|
||||||
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
|
|
||||||
if v == "" || len(v) > agentTraceMaxLen {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
for _, r := range v {
|
|
||||||
if unicode.IsControl(r) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
// BaseSecurityHeaders returns headers that every request must carry.
|
// BaseSecurityHeaders returns headers that every request must carry.
|
||||||
func BaseSecurityHeaders() http.Header {
|
func BaseSecurityHeaders() http.Header {
|
||||||
h := make(http.Header)
|
h := make(http.Header)
|
||||||
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
|
|||||||
h.Set(HeaderVersion, build.Version)
|
h.Set(HeaderVersion, build.Version)
|
||||||
h.Set(HeaderBuild, DetectBuildKind())
|
h.Set(HeaderBuild, DetectBuildKind())
|
||||||
h.Set(HeaderUserAgent, UserAgentValue())
|
h.Set(HeaderUserAgent, UserAgentValue())
|
||||||
if v := AgentTraceValue(); v != "" {
|
if v := envvars.AgentTrace(); v != "" {
|
||||||
h.Set(HeaderAgentTrace, v)
|
h.Set(HeaderAgentTrace, v)
|
||||||
}
|
}
|
||||||
return h
|
return h
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ package cmdutil
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/larksuite/cli/extension/credential"
|
"github.com/larksuite/cli/extension/credential"
|
||||||
@@ -264,88 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AgentTraceValue / HeaderAgentTrace
|
// HeaderAgentTrace injection (via BaseSecurityHeaders)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
|
|
||||||
if got := AgentTraceValue(); got != "trace-abc-123" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
|
|
||||||
if got := AgentTraceValue(); got != "trace-trim" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, " ")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsLF(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsTab(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
|
|
||||||
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
|
|
||||||
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
|
||||||
t.Setenv(envvars.CliAgentTrace, longVal)
|
|
||||||
if got := AgentTraceValue(); got != "" {
|
|
||||||
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
|
|
||||||
val := strings.Repeat("a", agentTraceMaxLen)
|
|
||||||
t.Setenv(envvars.CliAgentTrace, val)
|
|
||||||
if got := AgentTraceValue(); got != val {
|
|
||||||
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
|
||||||
t.Setenv(envvars.CliAgentTrace, "")
|
t.Setenv(envvars.CliAgentTrace, "")
|
||||||
h := BaseSecurityHeaders()
|
h := BaseSecurityHeaders()
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const (
|
|||||||
// Content safety scanning mode
|
// Content safety scanning mode
|
||||||
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
|
||||||
|
|
||||||
|
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
|
||||||
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
|
||||||
|
|
||||||
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"
|
||||||
|
|||||||
36
internal/envvars/read.go
Normal file
36
internal/envvars/read.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package envvars
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
agentNameMaxLen = 128
|
||||||
|
agentTraceMaxLen = 1024
|
||||||
|
)
|
||||||
|
|
||||||
|
func AgentName() string {
|
||||||
|
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func AgentTrace() string {
|
||||||
|
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSingleLine(raw string, maxLen int) string {
|
||||||
|
v := strings.TrimSpace(raw)
|
||||||
|
if v == "" || len(v) > maxLen {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
for _, r := range v {
|
||||||
|
if unicode.IsControl(r) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
131
internal/envvars/read_test.go
Normal file
131
internal/envvars/read_test.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package envvars
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty when env unset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_ReturnsCleanValue(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "claude-code")
|
||||||
|
if got := AgentName(); got != "claude-code" {
|
||||||
|
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_TrimsWhitespace(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, " cursor ")
|
||||||
|
if got := AgentName(); got != "cursor" {
|
||||||
|
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsControlChar(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentName, "agent\x01injected")
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() = %q, want empty for control char value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentName_RejectsOverlongValue(t *testing.T) {
|
||||||
|
longVal := strings.Repeat("a", agentNameMaxLen+1)
|
||||||
|
t.Setenv(CliAgentName, longVal)
|
||||||
|
if got := AgentName(); got != "" {
|
||||||
|
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "trace-abc-123")
|
||||||
|
if got := AgentTrace(); got != "trace-abc-123" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, " trace-trim ")
|
||||||
|
if got := AgentTrace(); got != "trace-trim" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, " ")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsCRLF(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsLF(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsTab(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\tinjected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsControlChar(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\x01injected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsDEL(t *testing.T) {
|
||||||
|
t.Setenv(CliAgentTrace, "val\x7finjected")
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
|
||||||
|
longVal := strings.Repeat("a", agentTraceMaxLen+1)
|
||||||
|
t.Setenv(CliAgentTrace, longVal)
|
||||||
|
if got := AgentTrace(); got != "" {
|
||||||
|
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
|
||||||
|
val := strings.Repeat("a", agentTraceMaxLen)
|
||||||
|
t.Setenv(CliAgentTrace, val)
|
||||||
|
if got := AgentTrace(); got != val {
|
||||||
|
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,8 +6,7 @@ package registry
|
|||||||
import "github.com/larksuite/cli/internal/apicatalog"
|
import "github.com/larksuite/cli/internal/apicatalog"
|
||||||
|
|
||||||
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
|
||||||
// metadata — deterministic across machines, for `lark-cli schema`, golden tests
|
// metadata — deterministic across machines, for golden tests and schema lint.
|
||||||
// and schema lint.
|
|
||||||
func EmbeddedCatalog() apicatalog.Catalog {
|
func EmbeddedCatalog() apicatalog.Catalog {
|
||||||
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
|
||||||
}
|
}
|
||||||
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
|
|||||||
func RuntimeCatalog() apicatalog.Catalog {
|
func RuntimeCatalog() apicatalog.Catalog {
|
||||||
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
|
||||||
|
// otherwise the merged runtime catalog. Binaries built from the bare Go module
|
||||||
|
// embed only the empty meta_data_default.json stub, so the embedded view has
|
||||||
|
// nothing to resolve; the merged view is the only data such binaries have.
|
||||||
|
func SchemaCatalog() apicatalog.Catalog {
|
||||||
|
if len(EmbeddedServicesTyped()) > 0 {
|
||||||
|
return EmbeddedCatalog()
|
||||||
|
}
|
||||||
|
return RuntimeCatalog()
|
||||||
|
}
|
||||||
|
|||||||
67
internal/registry/catalog_test.go
Normal file
67
internal/registry/catalog_test.go
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/apicatalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
|
||||||
|
// restores them (with a full state reset) on cleanup.
|
||||||
|
func swapEmbeddedMeta(t *testing.T, data []byte) {
|
||||||
|
t.Helper()
|
||||||
|
resetInit()
|
||||||
|
orig := embeddedMetaJSON
|
||||||
|
embeddedMetaJSON = data
|
||||||
|
t.Cleanup(func() {
|
||||||
|
waitBackgroundRefresh()
|
||||||
|
embeddedMetaJSON = orig
|
||||||
|
resetInit()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
|
||||||
|
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
|
||||||
|
|
||||||
|
c := SchemaCatalog()
|
||||||
|
|
||||||
|
if c.Source() != apicatalog.SourceEmbedded {
|
||||||
|
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
|
||||||
|
}
|
||||||
|
if _, ok := c.Service("embedded_svc"); !ok {
|
||||||
|
t.Fatal("expected embedded_svc from embedded metadata")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
|
||||||
|
// from the bare Go module (plugin builds): only the empty meta_data_default.json
|
||||||
|
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
|
||||||
|
// Init seeds via sync fetch.
|
||||||
|
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
|
||||||
|
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(200)
|
||||||
|
w.Write(testEnvelopeJSON("remote_svc"))
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
testMetaURL = ts.URL
|
||||||
|
|
||||||
|
c := SchemaCatalog()
|
||||||
|
|
||||||
|
if c.Source() != apicatalog.SourceRuntime {
|
||||||
|
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
|
||||||
|
}
|
||||||
|
if _, ok := c.Service("remote_svc"); !ok {
|
||||||
|
t.Fatal("expected remote_svc from runtime fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
|
|
||||||
"github.com/larksuite/cli/internal/core"
|
"github.com/larksuite/cli/internal/core"
|
||||||
"github.com/larksuite/cli/internal/meta"
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
"github.com/larksuite/cli/internal/update"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed scope_priorities.json scope_overrides.json
|
//go:embed scope_priorities.json scope_overrides.json
|
||||||
@@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) {
|
|||||||
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
|
||||||
|
|
||||||
if !brandChanged {
|
if !brandChanged {
|
||||||
if cached, err := loadCachedMerged(); err == nil {
|
// After a CLI upgrade the embedded data can be fresher than an old
|
||||||
|
// cache; an equal/older cache must not shadow it.
|
||||||
|
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
|
||||||
overlayMergedServices(cached)
|
overlayMergedServices(cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
102
internal/registry/loader_test.go
Normal file
102
internal/registry/loader_test.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/larksuite/cli/internal/core"
|
||||||
|
"github.com/larksuite/cli/internal/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedCache writes a cache file + cache meta for one service whose Title is
|
||||||
|
// marker, tagged with the given top-level data version and brand.
|
||||||
|
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
|
||||||
|
t.Helper()
|
||||||
|
cDir := filepath.Join(dir, "cache")
|
||||||
|
if err := os.MkdirAll(cDir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
reg := MergedRegistry{
|
||||||
|
Version: version,
|
||||||
|
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
|
||||||
|
}
|
||||||
|
data, _ := json.Marshal(reg)
|
||||||
|
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
|
||||||
|
mData, _ := json.Marshal(cm)
|
||||||
|
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
|
||||||
|
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
|
||||||
|
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
|
||||||
|
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
|
||||||
|
t.Helper()
|
||||||
|
embedded, _ := json.Marshal(MergedRegistry{
|
||||||
|
Version: embeddedVer,
|
||||||
|
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
|
||||||
|
})
|
||||||
|
swapEmbeddedMeta(t, embedded)
|
||||||
|
tmp := t.TempDir()
|
||||||
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
|
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
|
||||||
|
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
|
||||||
|
InitWithBrand(core.BrandFeishu)
|
||||||
|
}
|
||||||
|
|
||||||
|
func titleOf(t *testing.T, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
svc, ok := ServiceTyped(name)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("service %q not loaded", name)
|
||||||
|
}
|
||||||
|
return svc.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "2.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("older cache: got %q, want EMBEDDED", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "2.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||||
|
t.Errorf("newer cache: got %q, want CACHE", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
|
||||||
|
initWithCache(t, "1.0.0", "not-a-semver")
|
||||||
|
if got := titleOf(t, "svc"); got != "EMBEDDED" {
|
||||||
|
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
|
||||||
|
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
|
||||||
|
// plugin builds without compiled meta_data.json still get remote data.
|
||||||
|
initWithCache(t, "0.0.0", "1.0.0")
|
||||||
|
if got := titleOf(t, "svc"); got != "CACHE" {
|
||||||
|
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,9 +72,11 @@ func hasEmbeddedServices() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// testRegistry returns a minimal MergedRegistry with one service.
|
// testRegistry returns a minimal MergedRegistry with one service.
|
||||||
|
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
|
||||||
|
// so cache overlay passes the version gate in InitWithBrand.
|
||||||
func testRegistry(name string) MergedRegistry {
|
func testRegistry(name string) MergedRegistry {
|
||||||
return MergedRegistry{
|
return MergedRegistry{
|
||||||
Version: "test-1.0",
|
Version: "1.0.0",
|
||||||
Services: []meta.Service{
|
Services: []meta.Service{
|
||||||
{
|
{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestCacheHit_WithinTTL(t *testing.T) {
|
func TestCacheHit_WithinTTL(t *testing.T) {
|
||||||
resetInit()
|
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestNetworkError_SilentDegradation(t *testing.T) {
|
func TestNetworkError_SilentDegradation(t *testing.T) {
|
||||||
resetInit()
|
swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
|
||||||
tmp := t.TempDir()
|
tmp := t.TempDir()
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
|
||||||
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
|
||||||
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
|
|||||||
if data == nil {
|
if data == nil {
|
||||||
t.Fatal("expected non-nil data")
|
t.Fatal("expected non-nil data")
|
||||||
}
|
}
|
||||||
if reg.Version != "test-1.0" {
|
if reg.Version != "1.0.0" {
|
||||||
t.Errorf("expected version test-1.0, got %s", reg.Version)
|
t.Errorf("expected version 1.0.0, got %s", reg.Version)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ type InstallMethod int
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
InstallNpm InstallMethod = iota
|
InstallNpm InstallMethod = iota
|
||||||
|
InstallPnpm
|
||||||
InstallManual
|
InstallManual
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,22 +54,32 @@ var (
|
|||||||
|
|
||||||
// DetectResult holds installation detection results.
|
// DetectResult holds installation detection results.
|
||||||
type DetectResult struct {
|
type DetectResult struct {
|
||||||
Method InstallMethod
|
Method InstallMethod
|
||||||
ResolvedPath string
|
ResolvedPath string
|
||||||
NpmAvailable bool
|
NpmAvailable bool
|
||||||
|
PnpmAvailable bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
// CanAutoUpdate returns true if the CLI can update itself automatically.
|
||||||
func (d DetectResult) CanAutoUpdate() bool {
|
func (d DetectResult) CanAutoUpdate() bool {
|
||||||
return d.Method == InstallNpm && d.NpmAvailable
|
switch d.Method {
|
||||||
|
case InstallNpm:
|
||||||
|
return d.NpmAvailable
|
||||||
|
case InstallPnpm:
|
||||||
|
return d.PnpmAvailable
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
// ManualReason returns a human-readable explanation of why auto-update is unavailable.
|
||||||
func (d DetectResult) ManualReason() string {
|
func (d DetectResult) ManualReason() string {
|
||||||
if d.Method == InstallNpm && !d.NpmAvailable {
|
switch {
|
||||||
|
case d.Method == InstallNpm && !d.NpmAvailable:
|
||||||
return "installed via npm, but npm is not available in PATH"
|
return "installed via npm, but npm is not available in PATH"
|
||||||
|
case d.Method == InstallPnpm && !d.PnpmAvailable:
|
||||||
|
return "installed via pnpm, but pnpm is not available in PATH"
|
||||||
}
|
}
|
||||||
return "not installed via npm"
|
return "not installed via npm or pnpm"
|
||||||
}
|
}
|
||||||
|
|
||||||
// NpmResult holds the result of an npm install or skills update execution.
|
// NpmResult holds the result of an npm install or skills update execution.
|
||||||
@@ -92,6 +103,7 @@ func (r *NpmResult) CombinedOutput() string {
|
|||||||
type Updater struct {
|
type Updater struct {
|
||||||
DetectOverride func() DetectResult
|
DetectOverride func() DetectResult
|
||||||
NpmInstallOverride func(version string) *NpmResult
|
NpmInstallOverride func(version string) *NpmResult
|
||||||
|
PnpmInstallOverride func(version string) *NpmResult
|
||||||
SkillsIndexFetchOverride func() *NpmResult
|
SkillsIndexFetchOverride func() *NpmResult
|
||||||
SkillsCommandOverride func(args ...string) *NpmResult
|
SkillsCommandOverride func(args ...string) *NpmResult
|
||||||
VerifyOverride func(expectedVersion string) error
|
VerifyOverride func(expectedVersion string) error
|
||||||
@@ -101,17 +113,38 @@ type Updater struct {
|
|||||||
// running binary is successfully renamed to .old. Used by
|
// running binary is successfully renamed to .old. Used by
|
||||||
// CanRestorePreviousVersion to report whether rollback is possible.
|
// CanRestorePreviousVersion to report whether rollback is possible.
|
||||||
backupCreated bool
|
backupCreated bool
|
||||||
|
|
||||||
|
// detectCache memoizes the first real DetectInstallMethod result. How this
|
||||||
|
// binary was installed cannot change during a single process, so caching is
|
||||||
|
// the correct semantics — and it is required for correctness: the update
|
||||||
|
// flow mutates the install (pnpm add -g / npm install -g) before syncing
|
||||||
|
// skills, so a re-detection at skills time could resolve a now-stale
|
||||||
|
// os.Executable path and misclassify. Seeded pre-update by the first call
|
||||||
|
// (updateRun), it keeps the post-update skills launcher consistent with the
|
||||||
|
// launcher reported to the user. Not goroutine-safe; the update flow is
|
||||||
|
// sequential.
|
||||||
|
detectCache *DetectResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates an Updater with default (real) behavior.
|
// New creates an Updater with default (real) behavior.
|
||||||
func New() *Updater { return &Updater{} }
|
func New() *Updater { return &Updater{} }
|
||||||
|
|
||||||
// DetectInstallMethod determines how the CLI was installed and whether
|
// DetectInstallMethod determines how the CLI was installed and whether the
|
||||||
// npm is available for auto-update.
|
// owning package manager is available for auto-update.
|
||||||
func (u *Updater) DetectInstallMethod() DetectResult {
|
func (u *Updater) DetectInstallMethod() DetectResult {
|
||||||
if u.DetectOverride != nil {
|
if u.DetectOverride != nil {
|
||||||
return u.DetectOverride()
|
return u.DetectOverride()
|
||||||
}
|
}
|
||||||
|
if u.detectCache != nil {
|
||||||
|
return *u.detectCache
|
||||||
|
}
|
||||||
|
result := u.detectInstallMethod()
|
||||||
|
u.detectCache = &result
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectInstallMethod performs the real (uncached) detection.
|
||||||
|
func (u *Updater) detectInstallMethod() DetectResult {
|
||||||
exe, err := vfs.Executable()
|
exe, err := vfs.Executable()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return DetectResult{Method: InstallManual}
|
return DetectResult{Method: InstallManual}
|
||||||
@@ -120,24 +153,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
return DetectResult{Method: InstallManual, ResolvedPath: exe}
|
||||||
}
|
}
|
||||||
|
_, npmErr := exec.LookPath("npm")
|
||||||
|
_, pnpmErr := exec.LookPath("pnpm")
|
||||||
|
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// detectFromResolved classifies the resolved binary path into an install
|
||||||
|
// method and records package-manager availability. Split out from
|
||||||
|
// DetectInstallMethod so the classification is unit-testable without touching
|
||||||
|
// the filesystem or PATH.
|
||||||
|
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
|
||||||
method := InstallManual
|
method := InstallManual
|
||||||
if strings.Contains(resolved, "node_modules") {
|
if strings.Contains(resolved, "node_modules") {
|
||||||
method = InstallNpm
|
if containsPnpmMarker(resolved) {
|
||||||
}
|
method = InstallPnpm
|
||||||
|
} else {
|
||||||
npmAvailable := false
|
method = InstallNpm
|
||||||
if method == InstallNpm {
|
|
||||||
if _, err := exec.LookPath("npm"); err == nil {
|
|
||||||
npmAvailable = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
d := DetectResult{Method: method, ResolvedPath: resolved}
|
||||||
return DetectResult{
|
switch method {
|
||||||
Method: method,
|
case InstallNpm:
|
||||||
ResolvedPath: resolved,
|
d.NpmAvailable = npmOnPath
|
||||||
NpmAvailable: npmAvailable,
|
case InstallPnpm:
|
||||||
|
d.PnpmAvailable = pnpmOnPath
|
||||||
}
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsPnpmMarker reports whether the resolved binary path belongs to a
|
||||||
|
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
|
||||||
|
// (a ".pnpm" directory segment) and the global content-addressable store,
|
||||||
|
// whose resolved path runs through pnpm's home directory (e.g.
|
||||||
|
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
|
||||||
|
// followed by "store". Matching only these two shapes (rather than any bare
|
||||||
|
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
|
||||||
|
// a directory named "pnpm". Windows separators are normalized to "/" so the
|
||||||
|
// classification is OS-independent and unit-testable anywhere.
|
||||||
|
func containsPnpmMarker(p string) bool {
|
||||||
|
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
|
||||||
|
for i, part := range parts {
|
||||||
|
if part == ".pnpm" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
// RunNpmInstall executes npm install -g @larksuite/cli@<version>.
|
||||||
@@ -163,6 +226,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
|
||||||
|
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
|
||||||
|
if u.PnpmInstallOverride != nil {
|
||||||
|
return u.PnpmInstallOverride(version)
|
||||||
|
}
|
||||||
|
r := &NpmResult{}
|
||||||
|
pnpmPath, err := exec.LookPath("pnpm")
|
||||||
|
if err != nil {
|
||||||
|
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
|
||||||
|
defer cancel()
|
||||||
|
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
|
||||||
|
cmd.Stdout = &r.Stdout
|
||||||
|
cmd.Stderr = &r.Stderr
|
||||||
|
r.Err = cmd.Run()
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
|
||||||
if u.SkillsIndexFetchOverride != nil {
|
if u.SkillsIndexFetchOverride != nil {
|
||||||
return u.SkillsIndexFetchOverride()
|
return u.SkillsIndexFetchOverride()
|
||||||
@@ -261,19 +347,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
|
|||||||
return u.runSkillsCommand(args...)
|
return u.runSkillsCommand(args...)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
|
||||||
|
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
|
||||||
|
// pnpm-only environments (pnpm's standalone installer bundles Node without
|
||||||
|
// putting npm/npx on PATH) can still sync skills after a self-update.
|
||||||
|
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
|
||||||
|
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
|
||||||
|
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
|
||||||
|
// launcher selection is unit-testable on any platform.
|
||||||
|
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
|
||||||
|
if method == InstallPnpm && pnpmAvailable {
|
||||||
|
r := args
|
||||||
|
if len(r) > 0 && r[0] == "-y" {
|
||||||
|
r = r[1:]
|
||||||
|
}
|
||||||
|
return "pnpm", append([]string{"dlx"}, r...)
|
||||||
|
}
|
||||||
|
return "npx", args
|
||||||
|
}
|
||||||
|
|
||||||
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
|
||||||
if u.SkillsCommandOverride != nil {
|
if u.SkillsCommandOverride != nil {
|
||||||
return u.SkillsCommandOverride(args...)
|
return u.SkillsCommandOverride(args...)
|
||||||
}
|
}
|
||||||
r := &NpmResult{}
|
r := &NpmResult{}
|
||||||
npxPath, err := exec.LookPath("npx")
|
det := u.DetectInstallMethod()
|
||||||
|
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
|
||||||
|
binPath, err := exec.LookPath(launcher)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.Err = fmt.Errorf("npx not found in PATH: %w", err)
|
r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
cmd := exec.CommandContext(ctx, npxPath, args...)
|
cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
|
||||||
cmd.Stdout = &r.Stdout
|
cmd.Stdout = &r.Stdout
|
||||||
cmd.Stderr = &r.Stderr
|
cmd.Stderr = &r.Stderr
|
||||||
r.Err = cmd.Run()
|
r.Err = cmd.Run()
|
||||||
|
|||||||
@@ -371,3 +371,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
|
|||||||
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestContainsPnpmMarker(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
path string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
// Classic virtual-store layout (.pnpm segment).
|
||||||
|
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||||
|
// Global content-addressable store layout (pnpm 11): resolved path runs
|
||||||
|
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
|
||||||
|
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
|
||||||
|
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
|
||||||
|
// npm and non-package installs — no pnpm/.pnpm segment.
|
||||||
|
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
{"/usr/local/bin/lark-cli", false},
|
||||||
|
// Substrings that must NOT match: segment must be exactly .pnpm, or
|
||||||
|
// "pnpm" immediately followed by "store".
|
||||||
|
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
|
||||||
|
// living under a dir named pnpm) must not be misclassified as pnpm.
|
||||||
|
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if got := containsPnpmMarker(c.path); got != c.want {
|
||||||
|
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectInstallMethod_Pnpm(t *testing.T) {
|
||||||
|
u := &Updater{DetectOverride: nil}
|
||||||
|
u.DetectOverride = func() DetectResult {
|
||||||
|
// Exercise the real classification by feeding a resolved path via a small shim.
|
||||||
|
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
|
||||||
|
}
|
||||||
|
got := u.DetectInstallMethod()
|
||||||
|
if got.Method != InstallPnpm {
|
||||||
|
t.Errorf("Method = %v, want InstallPnpm", got.Method)
|
||||||
|
}
|
||||||
|
if !got.PnpmAvailable {
|
||||||
|
t.Errorf("PnpmAvailable = false, want true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
|
||||||
|
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
|
||||||
|
t.Errorf("npm path Method = %v, want InstallNpm", m)
|
||||||
|
}
|
||||||
|
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
|
||||||
|
t.Errorf("manual path Method = %v, want InstallManual", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanAutoUpdate_Pnpm(t *testing.T) {
|
||||||
|
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
|
||||||
|
t.Error("pnpm available should CanAutoUpdate")
|
||||||
|
}
|
||||||
|
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
|
||||||
|
t.Error("pnpm unavailable should not CanAutoUpdate")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManualReason_Pnpm(t *testing.T) {
|
||||||
|
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
|
||||||
|
t.Errorf("pnpm reason = %q", got)
|
||||||
|
}
|
||||||
|
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
|
||||||
|
t.Errorf("manual reason = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPnpmInstall_Override(t *testing.T) {
|
||||||
|
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
|
||||||
|
r := &NpmResult{}
|
||||||
|
r.Stdout.WriteString("added @larksuite/cli@" + version)
|
||||||
|
return r
|
||||||
|
}}
|
||||||
|
got := u.RunPnpmInstall("2.0.0")
|
||||||
|
if got.Err != nil {
|
||||||
|
t.Fatalf("unexpected err: %v", got.Err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
|
||||||
|
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRunPnpmInstall_Error(t *testing.T) {
|
||||||
|
wantErr := errors.New("boom")
|
||||||
|
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
|
||||||
|
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
|
||||||
|
t.Errorf("err = %v, want %v", got.Err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSkillsInvocation(t *testing.T) {
|
||||||
|
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
method InstallMethod
|
||||||
|
pnpmAvailable bool
|
||||||
|
args []string
|
||||||
|
wantLauncher string
|
||||||
|
wantRest []string
|
||||||
|
}{
|
||||||
|
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
|
||||||
|
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
|
||||||
|
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
|
||||||
|
"npx", addArgs},
|
||||||
|
{"npm install → npx unchanged", InstallNpm, false, addArgs,
|
||||||
|
"npx", addArgs},
|
||||||
|
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
|
||||||
|
"npx", []string{"-y", "skills", "ls", "-g"}},
|
||||||
|
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
|
||||||
|
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
|
||||||
|
if gotLauncher != c.wantLauncher {
|
||||||
|
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
|
||||||
|
}
|
||||||
|
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
|
||||||
|
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
|
||||||
|
// hazard: DetectInstallMethod must return the first (pre-update) detection on
|
||||||
|
// subsequent calls, so the skills launcher chosen after the binary is replaced
|
||||||
|
// stays consistent with what was detected — and reported — before the update.
|
||||||
|
func TestDetectInstallMethod_Caches(t *testing.T) {
|
||||||
|
u := New()
|
||||||
|
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
|
||||||
|
u.detectCache = &cached
|
||||||
|
got := u.DetectInstallMethod()
|
||||||
|
if got.Method != InstallPnpm || !got.PnpmAvailable {
|
||||||
|
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@larksuite/cli",
|
"name": "@larksuite/cli",
|
||||||
"version": "1.0.64",
|
"version": "1.0.65",
|
||||||
"description": "The official CLI for Lark/Feishu open platform",
|
"description": "The official CLI for Lark/Feishu open platform",
|
||||||
"bin": {
|
"bin": {
|
||||||
"lark-cli": "scripts/run.js"
|
"lark-cli": "scripts/run.js"
|
||||||
|
|||||||
@@ -67,6 +67,26 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
|
|||||||
return attendees, nil
|
return attendees, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func attendeesIncludeRoom(attendees []map[string]string) bool {
|
||||||
|
for _, attendee := range attendees {
|
||||||
|
if attendee["type"] == "resource" || attendee["room_id"] != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func guideApprovalRoomReasonError(err error, attendees []map[string]string) error {
|
||||||
|
if err == nil || !attendeesIncludeRoom(attendees) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok || !strings.Contains(strings.ToLower(p.Hint), "approval_reason") {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return withStepContext(err, "approval meeting rooms require attendees[].approval_reason; calendar +create does not expose this low-frequency field. Create the event with the raw API flow, then use `lark-cli calendar event.attendees create --as user` with attendees[].approval_reason for the room attendee.")
|
||||||
|
}
|
||||||
|
|
||||||
var CalendarCreate = common.Shortcut{
|
var CalendarCreate = common.Shortcut{
|
||||||
Service: "calendar",
|
Service: "calendar",
|
||||||
Command: "+create",
|
Command: "+create",
|
||||||
@@ -225,6 +245,7 @@ var CalendarCreate = common.Shortcut{
|
|||||||
"need_notification": true,
|
"need_notification": true,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
err = guideApprovalRoomReasonError(err, attendees)
|
||||||
// Rollback: delete the event
|
// Rollback: delete the event
|
||||||
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
_, rollbackErr := runtime.CallAPITyped("DELETE",
|
||||||
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
|
||||||
|
|||||||
@@ -673,6 +673,76 @@ func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreate_ApprovalRoomMissingReason_GuidesRawAttendeesAPI(t *testing.T) {
|
||||||
|
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
|
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": 0, "msg": "ok",
|
||||||
|
"data": map[string]interface{}{
|
||||||
|
"event": map[string]interface{}{
|
||||||
|
"event_id": "evt_approval_room",
|
||||||
|
"summary": "Approval Room",
|
||||||
|
"start_time": map[string]interface{}{"timestamp": "1742515200"},
|
||||||
|
"end_time": map[string]interface{}{"timestamp": "1742518800"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "POST",
|
||||||
|
URL: "/events/evt_approval_room/attendees",
|
||||||
|
Body: map[string]interface{}{
|
||||||
|
"code": codeInvalidParamsWithDetail,
|
||||||
|
"msg": "invalid params",
|
||||||
|
"error": map[string]interface{}{
|
||||||
|
"details": []interface{}{
|
||||||
|
map[string]interface{}{"value": "attendees[0].approval_reason is required for approval meeting rooms"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
reg.Register(&httpmock.Stub{
|
||||||
|
Method: "DELETE",
|
||||||
|
URL: "/events/evt_approval_room",
|
||||||
|
Body: map[string]interface{}{"code": 0, "msg": "ok"},
|
||||||
|
})
|
||||||
|
|
||||||
|
err := mountAndRun(t, CalendarCreate, []string{
|
||||||
|
"+create",
|
||||||
|
"--summary", "Approval Room",
|
||||||
|
"--start", "2025-03-21T00:00:00+08:00",
|
||||||
|
"--end", "2025-03-21T01:00:00+08:00",
|
||||||
|
"--calendar-id", "cal_test123",
|
||||||
|
"--attendee-ids", "omm_room1",
|
||||||
|
"--as", "user",
|
||||||
|
}, f, nil)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error for approval room missing approval_reason, got nil")
|
||||||
|
}
|
||||||
|
p, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("ProblemOf returned !ok for %T", err)
|
||||||
|
}
|
||||||
|
if p.Category != errs.CategoryAPI {
|
||||||
|
t.Errorf("category=%q, want %q", p.Category, errs.CategoryAPI)
|
||||||
|
}
|
||||||
|
if p.Subtype != errs.SubtypeInvalidParameters {
|
||||||
|
t.Errorf("subtype=%q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
|
||||||
|
}
|
||||||
|
if p.Code != codeInvalidParamsWithDetail {
|
||||||
|
t.Errorf("code=%d, want %d", p.Code, codeInvalidParamsWithDetail)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"approval_reason", "calendar event.attendees create", "--as user", "rolled back successfully"} {
|
||||||
|
if !strings.Contains(p.Hint, want) {
|
||||||
|
t.Errorf("hint should contain %q, got: %q", want, p.Hint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// When the add-attendees call fails AND the rollback DELETE also fails, the
|
// When the add-attendees call fails AND the rollback DELETE also fails, the
|
||||||
// primary error stays the add failure (classification preserved) and the Hint
|
// primary error stays the add failure (classification preserved) and the Hint
|
||||||
// must surface BOTH the rollback failure reason and the orphan event_id so the
|
// must surface BOTH the rollback failure reason and the orphan event_id so the
|
||||||
|
|||||||
261
shortcuts/doc/docs_history.go
Normal file
261
shortcuts/doc/docs_history.go
Normal file
@@ -0,0 +1,261 @@
|
|||||||
|
// 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 {
|
||||||
|
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
|
||||||
|
}
|
||||||
|
if 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
|
||||||
|
},
|
||||||
|
}
|
||||||
340
shortcuts/doc/docs_history_test.go
Normal file
340
shortcuts/doc/docs_history_test.go
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
// 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
|
||||||
|
category errs.Category
|
||||||
|
subtype errs.Subtype
|
||||||
|
wantCause bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "list rejects legacy doc URL",
|
||||||
|
shortcut: DocsHistoryList,
|
||||||
|
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
|
||||||
|
param: "--doc",
|
||||||
|
category: errs.CategoryValidation,
|
||||||
|
subtype: errs.SubtypeInvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list rejects invalid page size",
|
||||||
|
shortcut: DocsHistoryList,
|
||||||
|
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
|
||||||
|
param: "--page-size",
|
||||||
|
category: errs.CategoryValidation,
|
||||||
|
subtype: errs.SubtypeInvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "revert rejects non-numeric history version id",
|
||||||
|
shortcut: DocsHistoryRevert,
|
||||||
|
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
|
||||||
|
param: "--history-version-id",
|
||||||
|
category: errs.CategoryValidation,
|
||||||
|
subtype: errs.SubtypeInvalidArgument,
|
||||||
|
wantCause: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "revert rejects non-positive history version id",
|
||||||
|
shortcut: DocsHistoryRevert,
|
||||||
|
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
|
||||||
|
param: "--history-version-id",
|
||||||
|
category: errs.CategoryValidation,
|
||||||
|
subtype: errs.SubtypeInvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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",
|
||||||
|
category: errs.CategoryValidation,
|
||||||
|
subtype: errs.SubtypeInvalidArgument,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "status rejects empty task id",
|
||||||
|
shortcut: DocsHistoryRevertStatus,
|
||||||
|
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
|
||||||
|
param: "--task-id",
|
||||||
|
category: errs.CategoryValidation,
|
||||||
|
subtype: errs.SubtypeInvalidArgument,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("error is not typed: %T %v", err, err)
|
||||||
|
}
|
||||||
|
if problem.Category != tt.category {
|
||||||
|
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
|
||||||
|
}
|
||||||
|
if problem.Subtype != tt.subtype {
|
||||||
|
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, 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)
|
||||||
|
}
|
||||||
|
if tt.wantCause && errors.Unwrap(err) == nil {
|
||||||
|
t.Fatalf("expected wrapped cause, got nil (err: %v)", 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
|
|||||||
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
return docsSkillReadCommand + " references/lark-doc-fetch.md"
|
||||||
case "update":
|
case "update":
|
||||||
return docsSkillReadCommand + " references/lark-doc-update.md"
|
return docsSkillReadCommand + " references/lark-doc-update.md"
|
||||||
|
case "history-list", "history-revert", "history-revert-status":
|
||||||
|
return docsSkillReadCommand + " references/lark-doc-history.md"
|
||||||
default:
|
default:
|
||||||
return docsSkillReadCommand
|
return docsSkillReadCommand
|
||||||
}
|
}
|
||||||
@@ -44,6 +46,12 @@ func docsHelpCommandForShortcut(shortcut string) string {
|
|||||||
return "lark-cli docs +fetch --help"
|
return "lark-cli docs +fetch --help"
|
||||||
case "update":
|
case "update":
|
||||||
return "lark-cli docs +update --help"
|
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:
|
default:
|
||||||
return "lark-cli docs --help"
|
return "lark-cli docs --help"
|
||||||
}
|
}
|
||||||
@@ -56,6 +64,9 @@ func Shortcuts() []common.Shortcut {
|
|||||||
DocsCreate,
|
DocsCreate,
|
||||||
DocsFetch,
|
DocsFetch,
|
||||||
DocsUpdate,
|
DocsUpdate,
|
||||||
|
DocsHistoryList,
|
||||||
|
DocsHistoryRevert,
|
||||||
|
DocsHistoryRevertStatus,
|
||||||
DocMediaInsert,
|
DocMediaInsert,
|
||||||
DocMediaUpload,
|
DocMediaUpload,
|
||||||
DocMediaPreview,
|
DocMediaPreview,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -28,6 +29,8 @@ const (
|
|||||||
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
|
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
|
||||||
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
|
||||||
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
|
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
|
||||||
|
|
||||||
|
driveImportConcurrentOperationHint = "This import conflict means another operation is running in the same Drive location. Run batch imports to the same folder/root or target bitable serially. Wait a few seconds before retrying each failed import; retry each failed item at most 3 times, then stop and report the conflict."
|
||||||
)
|
)
|
||||||
|
|
||||||
// driveImportExtToDocTypes defines which source file extensions can be imported
|
// driveImportExtToDocTypes defines which source file extensions can be imported
|
||||||
@@ -47,6 +50,8 @@ var driveImportExtToDocTypes = map[string][]string{
|
|||||||
"pptx": {"slides"},
|
"pptx": {"slides"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001}
|
||||||
|
|
||||||
// driveImportSpec contains the user-facing import inputs after normalization.
|
// driveImportSpec contains the user-facing import inputs after normalization.
|
||||||
type driveImportSpec struct {
|
type driveImportSpec struct {
|
||||||
FilePath string
|
FilePath string
|
||||||
@@ -427,11 +432,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
|||||||
return status, true, nil
|
return status, true, nil
|
||||||
}
|
}
|
||||||
if status.Failed() {
|
if status.Failed() {
|
||||||
msg := strings.TrimSpace(status.JobErrorMsg)
|
return status, false, driveImportFailureError(status)
|
||||||
if msg == "" {
|
|
||||||
msg = status.StatusLabel()
|
|
||||||
}
|
|
||||||
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !hadSuccessfulPoll && lastErr != nil {
|
if !hadSuccessfulPoll && lastErr != nil {
|
||||||
@@ -440,3 +441,40 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
|
|||||||
|
|
||||||
return lastStatus, false, nil
|
return lastStatus, false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func driveImportFailureError(status driveImportStatus) *errs.APIError {
|
||||||
|
msg := strings.TrimSpace(status.JobErrorMsg)
|
||||||
|
if msg == "" {
|
||||||
|
msg = status.StatusLabel()
|
||||||
|
}
|
||||||
|
|
||||||
|
apiErr := errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
|
||||||
|
if code, ok := driveImportConcurrentOperationCode(msg); ok {
|
||||||
|
apiErr = apiErr.WithCode(code).WithRetryable().WithHint(driveImportConcurrentOperationHint)
|
||||||
|
}
|
||||||
|
return apiErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func driveImportConcurrentOperationCode(msg string) (int, bool) {
|
||||||
|
for _, code := range driveImportConcurrentOperationCodes {
|
||||||
|
codeText := strconv.Itoa(code)
|
||||||
|
for idx := strings.Index(msg, codeText); idx >= 0; {
|
||||||
|
end := idx + len(codeText)
|
||||||
|
if (idx == 0 || !isASCIIDigit(msg[idx-1])) && (end == len(msg) || !isASCIIDigit(msg[end])) {
|
||||||
|
return code, true
|
||||||
|
}
|
||||||
|
|
||||||
|
nextStart := idx + 1
|
||||||
|
next := strings.Index(msg[nextStart:], codeText)
|
||||||
|
if next < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
idx = nextStart + next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isASCIIDigit(ch byte) bool {
|
||||||
|
return ch >= '0' && ch <= '9'
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -211,6 +212,82 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDriveImportFailureErrorAddsConcurrentOperationGuidance(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, code := range driveImportConcurrentOperationCodes {
|
||||||
|
t.Run(strconv.Itoa(code), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := driveImportFailureError(driveImportStatus{
|
||||||
|
JobStatus: 3,
|
||||||
|
JobErrorMsg: "call CreateObjNode return error code, code: " + strconv.Itoa(code) + ", message:",
|
||||||
|
})
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T", err)
|
||||||
|
}
|
||||||
|
if problem.Category != errs.CategoryAPI {
|
||||||
|
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
|
||||||
|
}
|
||||||
|
if problem.Subtype != errs.SubtypeServerError {
|
||||||
|
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeServerError)
|
||||||
|
}
|
||||||
|
if problem.Code != code {
|
||||||
|
t.Fatalf("code = %d, want %d", problem.Code, code)
|
||||||
|
}
|
||||||
|
if !problem.Retryable {
|
||||||
|
t.Fatal("expected retryable error")
|
||||||
|
}
|
||||||
|
if problem.Hint != driveImportConcurrentOperationHint {
|
||||||
|
t.Fatalf("hint = %q, want %q", problem.Hint, driveImportConcurrentOperationHint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDriveImportFailureErrorLeavesOtherFailuresUnchanged(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
msg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ordinary failure",
|
||||||
|
msg: "unsupported conversion",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "longer numeric code containing known code",
|
||||||
|
msg: "call CreateObjNode return error code, code: 12321401012, message:",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := driveImportFailureError(driveImportStatus{
|
||||||
|
JobStatus: 3,
|
||||||
|
JobErrorMsg: tt.msg,
|
||||||
|
})
|
||||||
|
problem, ok := errs.ProblemOf(err)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected typed error, got %T", err)
|
||||||
|
}
|
||||||
|
if problem.Code != 0 {
|
||||||
|
t.Fatalf("code = %d, want 0", problem.Code)
|
||||||
|
}
|
||||||
|
if problem.Retryable {
|
||||||
|
t.Fatal("expected non-concurrency failure to remain non-retryable")
|
||||||
|
}
|
||||||
|
if problem.Hint != "" {
|
||||||
|
t.Fatalf("hint = %q, want empty", problem.Hint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
|
|||||||
AuthTypes: []string{"user", "bot"},
|
AuthTypes: []string{"user", "bot"},
|
||||||
HasFormat: true,
|
HasFormat: true,
|
||||||
Flags: []common.Flag{
|
Flags: []common.Flag{
|
||||||
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"},
|
{Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
|
||||||
|
|
||||||
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
|
||||||
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},
|
||||||
|
|||||||
@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
|
|||||||
},
|
},
|
||||||
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
|
||||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||||
dr := common.NewDryRunAPI()
|
return common.NewDryRunAPI().
|
||||||
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" {
|
PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
||||||
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
|
|
||||||
}
|
|
||||||
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
|
|
||||||
Body(buildSpeakerReplaceRequestBody(runtime))
|
Body(buildSpeakerReplaceRequestBody(runtime))
|
||||||
},
|
},
|
||||||
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
|
||||||
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
|
||||||
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
|
||||||
|
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
|
||||||
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
|
||||||
|
|
||||||
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken)
|
_, err := runtime.CallAPITyped(http.MethodPut,
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = runtime.CallAPITyped(http.MethodPut,
|
|
||||||
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
|
||||||
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID))
|
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
|
||||||
}
|
}
|
||||||
|
|
||||||
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
|
|||||||
return body
|
return body
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
|
||||||
out := map[string]interface{}{
|
out := map[string]interface{}{
|
||||||
"minute_token": minuteToken,
|
"minute_token": minuteToken,
|
||||||
"to_user_id": toUserID,
|
"to_user_id": toUserID,
|
||||||
}
|
}
|
||||||
if fromSpeakerID != "" {
|
if fromSpeakerID != "" {
|
||||||
out["from_speaker_id"] = fromSpeakerID
|
out["from_speaker_id"] = fromSpeakerID
|
||||||
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
|
|
||||||
out["from_speaker_input"] = fromSpeakerInput
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
out["from_user_id"] = fromUserID
|
out["from_user_id"] = fromUserID
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string {
|
func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
|
||||||
if fromSpeakerInput != "" {
|
|
||||||
return fromSpeakerInput
|
|
||||||
}
|
|
||||||
if fromSpeakerID != "" {
|
if fromSpeakerID != "" {
|
||||||
return fromSpeakerID
|
return fromSpeakerID
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
|
||||||
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
|
|
||||||
warmTokenCache(t)
|
|
||||||
|
|
||||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
|
||||||
"+speaker-replace",
|
|
||||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
|
||||||
"--from-speaker-id", "说话人1",
|
|
||||||
"--to-user-id", "ou_new_speaker",
|
|
||||||
"--dry-run", "--as", "user",
|
|
||||||
}, f, stdout)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
out := stdout.String()
|
|
||||||
if !strings.Contains(out, "GET") {
|
|
||||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "/transcript/speakerlist") {
|
|
||||||
t.Errorf("expected speakerlist path, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "PUT") {
|
|
||||||
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
|
||||||
}
|
|
||||||
if !strings.Contains(out, "ou_new_speaker") {
|
|
||||||
t.Errorf("expected to_user_id in body, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
|
||||||
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
|
||||||
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
|
||||||
warmTokenCache(t)
|
warmTokenCache(t)
|
||||||
|
|
||||||
reg.Register(&httpmock.Stub{
|
// Only the PUT is registered on purpose: an opaque speaker_id must be passed
|
||||||
Method: http.MethodGet,
|
// straight through without a second speakerlist call. If the code still
|
||||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist",
|
// prefetched speakerlist, the unregistered GET would fail the request.
|
||||||
Body: map[string]interface{}{
|
|
||||||
"code": 0,
|
|
||||||
"msg": "ok",
|
|
||||||
"data": map[string]interface{}{
|
|
||||||
"speakers": []interface{}{
|
|
||||||
map[string]interface{}{
|
|
||||||
"speaker_id": "ENCRYPTED_TOKEN_ABC",
|
|
||||||
"name": "说话人1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
reg.Register(&httpmock.Stub{
|
reg.Register(&httpmock.Stub{
|
||||||
Method: http.MethodPut,
|
Method: http.MethodPut,
|
||||||
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
|
||||||
@@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
|||||||
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
err := mountAndRun(t, MinutesSpeakerReplace, []string{
|
||||||
"+speaker-replace",
|
"+speaker-replace",
|
||||||
"--minute-token", minutesSpeakerReplaceTestToken,
|
"--minute-token", minutesSpeakerReplaceTestToken,
|
||||||
"--from-speaker-id", "说话人1",
|
"--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
|
||||||
"--to-user-id", "ou_new_speaker",
|
"--to-user-id", "ou_new_speaker",
|
||||||
"--format", "json", "--as", "user",
|
"--format", "json", "--as", "user",
|
||||||
}, f, stdout)
|
}, f, stdout)
|
||||||
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
|
|||||||
|
|
||||||
var envelope struct {
|
var envelope struct {
|
||||||
Data struct {
|
Data struct {
|
||||||
MinuteToken string `json:"minute_token"`
|
FromSpeakerID string `json:"from_speaker_id"`
|
||||||
FromSpeakerInput string `json:"from_speaker_input"`
|
ToUserID string `json:"to_user_id"`
|
||||||
FromSpeakerID string `json:"from_speaker_id"`
|
|
||||||
ToUserID string `json:"to_user_id"`
|
|
||||||
} `json:"data"`
|
} `json:"data"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
|
||||||
t.Fatalf("unmarshal stdout: %v", err)
|
t.Fatalf("unmarshal stdout: %v", err)
|
||||||
}
|
}
|
||||||
if envelope.Data.FromSpeakerInput != "说话人1" {
|
|
||||||
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
|
|
||||||
}
|
|
||||||
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
|
||||||
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
|
||||||
}
|
}
|
||||||
|
if envelope.Data.ToUserID != "ou_new_speaker" {
|
||||||
|
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
||||||
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
out := stdout.String()
|
out := stdout.String()
|
||||||
if !strings.Contains(out, "GET") {
|
if strings.Contains(out, "/transcript/speakerlist") {
|
||||||
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
|
t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "PUT") {
|
||||||
|
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
|
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
|
||||||
t.Errorf("expected from_speaker_id in body, got:\n%s", out)
|
t.Errorf("expected from_speaker_id in body, got:\n%s", out)
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package minutes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
"github.com/larksuite/cli/internal/validate"
|
|
||||||
"github.com/larksuite/cli/shortcuts/common"
|
|
||||||
)
|
|
||||||
|
|
||||||
type minuteSpeaker struct {
|
|
||||||
SpeakerID string
|
|
||||||
Name string
|
|
||||||
}
|
|
||||||
|
|
||||||
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
|
|
||||||
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
|
|
||||||
}
|
|
||||||
|
|
||||||
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
|
|
||||||
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if data == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
items := common.GetSlice(data, "speakers")
|
|
||||||
speakers := make([]minuteSpeaker, 0, len(items))
|
|
||||||
for _, raw := range items {
|
|
||||||
item, _ := raw.(map[string]interface{})
|
|
||||||
if item == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
|
|
||||||
name := strings.TrimSpace(common.GetString(item, "name"))
|
|
||||||
if id == "" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
|
|
||||||
}
|
|
||||||
return speakers, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
var matches []minuteSpeaker
|
|
||||||
for _, s := range speakers {
|
|
||||||
if s.Name == name {
|
|
||||||
matches = append(matches, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
switch len(matches) {
|
|
||||||
case 0:
|
|
||||||
return "", errs.NewValidationError(errs.SubtypeNotFound,
|
|
||||||
"no speaker named %q in minute transcript", name).
|
|
||||||
WithParam("--from-speaker-id").
|
|
||||||
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
|
|
||||||
case 1:
|
|
||||||
return matches[0].SpeakerID, nil
|
|
||||||
default:
|
|
||||||
ids := make([]string, len(matches))
|
|
||||||
for i, m := range matches {
|
|
||||||
ids[i] = m.SpeakerID
|
|
||||||
}
|
|
||||||
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
|
|
||||||
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
|
|
||||||
WithParam("--from-speaker-id").
|
|
||||||
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
|
|
||||||
// The input may already be an opaque speaker_id, or a display name that requires
|
|
||||||
// an internal speaker-list fetch.
|
|
||||||
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
|
|
||||||
input = strings.TrimSpace(input)
|
|
||||||
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
for _, s := range speakers {
|
|
||||||
if s.SpeakerID == input {
|
|
||||||
return input, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return resolveSpeakerIDByName(speakers, input)
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
|
|
||||||
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
|
|
||||||
if fromUserID != "" {
|
|
||||||
return "", fromUserID, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
|
|
||||||
return fromSpeakerID, "", err
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
|
||||||
// SPDX-License-Identifier: MIT
|
|
||||||
|
|
||||||
package minutes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/larksuite/cli/errs"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestResolveSpeakerIDByName(t *testing.T) {
|
|
||||||
speakers := []minuteSpeaker{
|
|
||||||
{SpeakerID: "id_a", Name: "Alice"},
|
|
||||||
{SpeakerID: "id_b", Name: "Bob"},
|
|
||||||
{SpeakerID: "id_c", Name: "Alice"},
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := resolveSpeakerIDByName(speakers, "Bob")
|
|
||||||
if err != nil || id != "id_b" {
|
|
||||||
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = resolveSpeakerIDByName(speakers, "Carol")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected not found error")
|
|
||||||
}
|
|
||||||
var ve *errs.ValidationError
|
|
||||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
|
|
||||||
t.Fatalf("want not-found validation error, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err = resolveSpeakerIDByName(speakers, "Alice")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected duplicate name error")
|
|
||||||
}
|
|
||||||
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
|
|
||||||
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
|
|
||||||
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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) {
|
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
|
||||||
program := &cobra.Command{Use: "root"}
|
program := &cobra.Command{Use: "root"}
|
||||||
RegisterShortcuts(program, newRegisterTestFactory(t))
|
RegisterShortcuts(program, newRegisterTestFactory(t))
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||||
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`。
|
||||||
|
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
|
||||||
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
- 用户要在云空间里新建文件夹,优先使用 `lark-cli drive +create-folder`。
|
||||||
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
- `lark-base` 只负责导入完成后的 Base 内部操作(表、字段、记录、视图),不要在“本地文件 -> Base”这一步提前切到 `lark-base`。
|
||||||
|
|
||||||
@@ -194,6 +195,7 @@ lark-cli drive file.comments list --params '{"file_token": "xxx", "file_type": "
|
|||||||
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
||||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) |
|
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) |
|
||||||
|
| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg`) | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
|
||||||
|
|
||||||
### 授权当前应用访问文档
|
### 授权当前应用访问文档
|
||||||
|
|
||||||
|
|||||||
@@ -45,13 +45,15 @@ lark-cli calendar +agenda --as user
|
|||||||
| 场景 | 前置要求 |
|
| 场景 | 前置要求 |
|
||||||
|------|----------|
|
|------|----------|
|
||||||
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
| 预约日程/会议、查会议室 | 先读 [lark-calendar-schedule-meeting.md](references/lark-calendar-schedule-meeting.md) |
|
||||||
| 编辑已有日程 | 先定位目标日程 `event_id`;若是重复性日程,必须定位到具体实例的 `event_id`(禁止使用原重复日程 ID) |
|
| 编辑已有日程 | 先定位目标日程 `event_id` |
|
||||||
|
| 编辑/删除重复性日程 | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),按操作范围(仅此次/全部/此次及后续)执行 |
|
||||||
| 删除/修改后验证 | 等待 2 秒再查询(API 最终一致性),不要告知用户你等待了 |
|
| 删除/修改后验证 | 等待 2 秒再查询(API 最终一致性),不要告知用户你等待了 |
|
||||||
| 调用任何 Shortcut | 先读其对应 reference 文档 |
|
| 调用任何 Shortcut | 先读其对应 reference 文档 |
|
||||||
|
|
||||||
## 核心概念
|
## 核心概念
|
||||||
|
|
||||||
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。操作重复日程的某次实例时,必须先定位该实例的 `event_id`,禁止使用原重复日程的 `event_id`。
|
- **日程实例(Instance)**:重复性日程展开后的具体时间实例。「仅此次」操作时使用具体实例的 `event_id`;「全部」或「此次及后续」操作时需对原重复性日程操作(使用原日程 `event_id`),并按需处理例外。
|
||||||
|
- **重复性日程例外(Exception)**:对重复性日程某次实例做过「仅此次」编辑后产生的独立日程(拥有独立 `event_id`)。删除/更新「全部」时必须同时处理例外,否则例外会残留。
|
||||||
- **全天日程(All-day Event)**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
|
- **全天日程(All-day Event)**:只按日期占用、没有具体起止时刻的日程,结束日期是包含在日程时间内的。
|
||||||
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
|
- **时间块 vs 时间范围**:时间块是具体确定的连续时间段(如 `14:00~15:00`),时间范围是泛指(如"今天下午")。`+room-find` 必须基于确定时间块,不能基于模糊范围。
|
||||||
- **会议室(Room)**:"room"不是"房间",是"会议室"。会议室是日程的一种参与人(resource attendee),不能脱离日程单独预定。
|
- **会议室(Room)**:"room"不是"房间",是"会议室"。会议室是日程的一种参与人(resource attendee),不能脱离日程单独预定。
|
||||||
@@ -71,6 +73,7 @@ lark-cli calendar +agenda --as user
|
|||||||
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) |
|
| 从日程获取关联的视频会议 ID 或用户绑定的会议纪要文档 | 本 skill(`+meeting`) |
|
||||||
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
|
| 从日程进一步拿 AI 智能纪要 / 逐字稿 / 妙记产物 | 先 `+meeting` 取 `meeting_id`,再 [`vc +detail`](../lark-vc/references/lark-vc-detail.md) → [`note +detail`](../lark-note/references/lark-note-detail.md) / [`minutes +detail`](../lark-minutes/references/lark-minutes-detail.md) |
|
||||||
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
| 预约/改约日程、添加/移除参会人、添加/更换会议室、调整时间 | 先判断新建 vs 编辑,再进入 [schedule-meeting 工作流](references/lark-calendar-schedule-meeting.md) |
|
||||||
|
| 编辑/删除重复性日程(「改这个重复日程」「删掉后面的」「全部取消」等) | 先读 [重复性日程操作规范](references/lark-calendar-recurring.md),确认操作范围后执行 |
|
||||||
|
|
||||||
## 任务类型分流
|
## 任务类型分流
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ lark-cli calendar +create --summary "..." --start "..." --end "..." \
|
|||||||
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
> 自动设置 `reminders: [{"minutes": 5}]`,默认日程开始前 5 分钟提醒。
|
||||||
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
|
> 自动设置 `vchat: {"vc_type": "vc"}`,默认日程包含飞书视频会议。如需其他视频会议类型或不含视频会议,请使用完整 API 命令。
|
||||||
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
|
> 失败保护:若添加参会人失败(如 open_id 错误),CLI 会自动删除刚创建的空日程(回滚,不通知参会人)。
|
||||||
|
> 审批会议室:`+create` 不暴露低频字段 `attendees[].approval_reason`。如果会议室要求审批,请使用用户身份先创建日程,再用完整 API `calendar event.attendees create --as user` 添加会议室并传 `approval_reason`。
|
||||||
|
|
||||||
## 高级用法(完整 API 命令)
|
## 高级用法(完整 API 命令)
|
||||||
|
|
||||||
@@ -72,9 +73,16 @@ lark-cli calendar events create \
|
|||||||
lark-cli schema calendar.event.attendees.create
|
lark-cli schema calendar.event.attendees.create
|
||||||
## 添加参会人
|
## 添加参会人
|
||||||
lark-cli calendar event.attendees create \
|
lark-cli calendar event.attendees create \
|
||||||
|
--as user \
|
||||||
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
||||||
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
|
--data '{"attendees": [{"type": "user", "user_id": "ou_xxx"}]}'
|
||||||
|
|
||||||
|
## 添加需要审批的会议室(approval_reason 最大 200 字符)
|
||||||
|
lark-cli calendar event.attendees create \
|
||||||
|
--as user \
|
||||||
|
--params '{"calendar_id":"<CALENDAR_ID>","event_id":"<EVENT_ID>"}' \
|
||||||
|
--data '{"attendees": [{"type": "resource", "room_id": "omm_xxx", "approval_reason": "申请原因"}]}'
|
||||||
|
|
||||||
# 可选第三步(推荐):若第二步失败,回滚删除空日程
|
# 可选第三步(推荐):若第二步失败,回滚删除空日程
|
||||||
## 查看完整参数定义
|
## 查看完整参数定义
|
||||||
lark-cli schema calendar.events.delete
|
lark-cli schema calendar.events.delete
|
||||||
|
|||||||
90
skills/lark-calendar/references/lark-calendar-recurring.md
Normal file
90
skills/lark-calendar/references/lark-calendar-recurring.md
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# 重复性日程操作规范
|
||||||
|
|
||||||
|
重复性日程的编辑/删除分为三种范围:「仅此次」「全部」「此次及后续」。用户未明确范围时,**必须询问确认**。
|
||||||
|
|
||||||
|
## 关键概念
|
||||||
|
|
||||||
|
- **event_id 结构**:`event_id` 的格式为 `{event_uid}_{originalTime}`。普通日程或重复性日程本体的 `originalTime` 为 `0`;例外的 `originalTime > 0`,代表该例外在原重复性序列中本来的时间位置。因此 `{event_uid}_0` 即为原重复性日程的 `event_id`。
|
||||||
|
- **原重复性日程**:携带 `rrule` 的日程本体,`event_id` 形如 `{event_uid}_0`。系列的所有属性(标题、时间、rrule、描述等)都挂在本体上。
|
||||||
|
- **例外(Exception)**:对某次实例做过「仅此次」编辑后产生的独立日程,`event_id` 形如 `{event_uid}_{originalTime}`(`originalTime > 0`)。通过 `event_uid` 部分即可关联回原重复性日程。
|
||||||
|
- 删除/更新原重复性日程 **不会** 级联处理例外——必须手动逐个处理。
|
||||||
|
|
||||||
|
## 前置步骤(所有范围通用)
|
||||||
|
|
||||||
|
1. 通过 `+agenda` 或 `+search-event` 定位重复性日程,获取原重复性日程的 `event_id`。
|
||||||
|
2. 通过 `events instance_view` 或 `+agenda` 列出实例,识别哪些是例外(`event_id` 中 `originalTime > 0` 的即为例外)。
|
||||||
|
3. 确认用户的操作范围。
|
||||||
|
|
||||||
|
## 编辑全部(更新时间)
|
||||||
|
|
||||||
|
| 步骤 | 命令 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --start ... --end ...` | 更新原重复性日程的时间 |
|
||||||
|
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 时间变更后例外已无意义,必须删除 |
|
||||||
|
|
||||||
|
> 理由:更新时间会改变重复起止点,例外日程的原始占位已变,若保留会导致时间冲突或残留。
|
||||||
|
|
||||||
|
## 编辑全部(更新非时间字段)
|
||||||
|
|
||||||
|
| 步骤 | 命令 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --summary ... --description ...` | 更新原重复性日程的标题/描述等 |
|
||||||
|
| 2 | `lark-cli calendar +update --event-id <例外ID> --summary ... --description ...` (逐个) | 同步更新例外日程的对应字段 |
|
||||||
|
|
||||||
|
> 理由:例外已脱离原重复性日程独立存在,不会自动继承原日程的更新。
|
||||||
|
|
||||||
|
## 删除全部
|
||||||
|
|
||||||
|
| 步骤 | 命令 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<原重复日程ID>","need_notification":true}'` | 删除重复性日程本体 |
|
||||||
|
| 2 | `lark-cli calendar events delete --params '{"calendar_id":"<CAL_ID>","event_id":"<例外ID>","need_notification":false}'` (逐个) | 删除所有例外日程 |
|
||||||
|
|
||||||
|
> 理由:例外是独立实体,删除原重复性日程不会级联删除例外。
|
||||||
|
|
||||||
|
## 编辑此次及后续
|
||||||
|
|
||||||
|
| 步骤 | 命令 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程(UNTIL 设为指定时间前一次实例的日期) |
|
||||||
|
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
|
||||||
|
| 3 | `lark-cli calendar +create --summary ... --start <指定时间> --end ... --rrule "FREQ=..." --attendee-ids ...` | 从指定时间开始创建新的重复性日程(即「后续」部分,携带编辑后的内容) |
|
||||||
|
|
||||||
|
> UNTIL 计算规则:若用户选择「从第 N 次开始编辑」,UNTIL 应设置为第 N-1 次实例的日期(即保留到指定时间之前的最后一次)。
|
||||||
|
> 新日程应继承原日程的参会人、会议室等配置(除非用户明确要修改)。
|
||||||
|
|
||||||
|
## 删除此次及后续
|
||||||
|
|
||||||
|
| 步骤 | 命令 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 1 | `lark-cli calendar +update --event-id <原重复日程ID> --rrule "FREQ=...;UNTIL=<截止日期>"` | 截短原重复性日程(UNTIL 设为指定时间前一次实例的日期) |
|
||||||
|
| 2 | `lark-cli calendar events delete ...` (逐个) | 删除指定时间之后(含)的例外日程 |
|
||||||
|
|
||||||
|
> 与「编辑此次及后续」的区别:不需要步骤 3(创建新的重复性日程),因为目标是删除后续而非替换。
|
||||||
|
|
||||||
|
## 仅此次
|
||||||
|
|
||||||
|
- **编辑仅此次**:通过 `+agenda` / `+search-event` 定位到具体实例的 `event_id`,然后正常调用 `+update`。
|
||||||
|
- **删除仅此次**:定位到具体实例的 `event_id`,调用 `events delete`。
|
||||||
|
|
||||||
|
## 用户意图映射
|
||||||
|
|
||||||
|
| 用户表达 | 操作范围 |
|
||||||
|
|----------|----------|
|
||||||
|
| 「改这个重复日程的标题」「全部改」「每次都改」 | 编辑全部 |
|
||||||
|
| 「删掉这个重复日程」「取消所有」 | 删除全部 |
|
||||||
|
| 「从下周开始改时间」「后面的都改」 | 编辑此次及后续 |
|
||||||
|
| 「从下周开始不要了」「后面的都删」 | 删除此次及后续 |
|
||||||
|
| 「就改这一次」「只删这一次」 | 仅此次 |
|
||||||
|
| 未明确范围 | **必须询问用户** |
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
- 涉及时间戳计算(如推算 UNTIL 日期)时,必须调用系统命令或脚本,禁止心算。
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
- [lark-calendar](../SKILL.md) — 日历全部命令
|
||||||
|
- [lark-calendar-update](lark-calendar-update.md) — 更新日程 Shortcut
|
||||||
|
- [lark-calendar-create](lark-calendar-create.md) — 创建日程 Shortcut
|
||||||
|
- [lark-shared](../../lark-shared/SKILL.md) — 认证和全局参数
|
||||||
@@ -43,7 +43,7 @@ lark-cli calendar +update \
|
|||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
| 参数 | 必填 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程要先定位到目标实例的 `event_id`,不要直接使用原重复日程 ID |
|
| `--event-id <id>` | 是 | 要更新的日程 ID。重复性日程请根据操作范围选择 ID,详见 [重复性日程操作规范](lark-calendar-recurring.md) |
|
||||||
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用 `primary`) |
|
| `--calendar-id <id>` | 否 | 日历 ID(省略则使用 `primary`) |
|
||||||
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
|
| `--summary <text>` | 否 | 新日程标题。仅在显式传入 `--summary` 时更新;若传空字符串,会把标题清空 |
|
||||||
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
|
| `--description <text>` | 否 | 新日程描述。目前 API 方式不支持编辑富文本描述;如果日程描述通过客户端编辑为富文本内容,则使用 API 更新描述会导致富文本格式丢失。仅在显式传入 `--description` 时更新;若传空字符串,会把描述清空 |
|
||||||
@@ -65,7 +65,7 @@ lark-cli calendar +update \
|
|||||||
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids` 或 `--remove-attendee-ids`。
|
- 只想修改标题、描述、时间或重复规则时,不需要同时传 `--add-attendee-ids` 或 `--remove-attendee-ids`。
|
||||||
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
|
- 如需替换某个参与人、群组或会议室,使用 `--remove-attendee-ids <旧ID>` + `--add-attendee-ids <新ID>`。
|
||||||
- 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
|
- 会议室是 resource attendee,必须使用 `omm_` ID 添加到参会人列表,不能脱离日程单独预定。
|
||||||
- 更新重复性日程的某一次实例时,必须先通过 `+agenda`、`+search-event` 或实例视图定位该实例的 `event_id`。
|
- 更新重复性日程时,必须先确定操作范围(仅此次/全部/此次及后续),然后按 [重复性日程操作规范](lark-calendar-recurring.md) 执行。
|
||||||
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
|
- 如果需要验证更新结果,等待至少 2 秒后再查询,避免同步延迟导致读到旧数据。
|
||||||
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
- 当同一次命令组合多个动作时,执行顺序为“日程字段 -> 移除参会人 -> 添加参会人”。若中途失败,不会自动回滚已成功步骤;错误信息会说明已完成的步骤。
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ description: "飞书云文档(Docx / Wiki 文档):读取和编辑飞书文
|
|||||||
metadata:
|
metadata:
|
||||||
requires:
|
requires:
|
||||||
bins: ["lark-cli"]
|
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; lark-cli mindnotes nodes list --help; lark-cli mindnotes nodes create --help"
|
cliHelp: "lark-cli docs --help;lark-cli mindnotes --help"
|
||||||
---
|
---
|
||||||
|
|
||||||
# docs
|
# docs
|
||||||
@@ -24,12 +24,12 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
|||||||
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
**CRITICAL — 执行对应操作前,MUST 先用 Read 工具读取以下文件,缺一不可:**
|
||||||
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
1. [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md) — 认证、权限处理、全局参数(所有操作通用)
|
||||||
2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
2. **读取文档(`docs +fetch`)** → 必读 [`lark-doc-fetch.md`](references/lark-doc-fetch.md)(`--scope` / `--detail` 选择、局部读取策略、`<fragment>` / `<excerpt>` 输出结构)
|
||||||
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和 [`lark-doc-style.md`](references/style/lark-doc-style.md)(元素选择、丰富度规则、颜色语义);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
3. **创建或编辑文档内容** → 必读 [`lark-doc-xml.md`](references/lark-doc-xml.md)(XML 语法规则,仅当用户明确要求 Markdown 时改读 [`lark-doc-md.md`](references/lark-doc-md.md))和必读 [`lark-doc-style.md`](references/style/lark-doc-style.md)(写作原则:默认段落、按体裁、组件克制);从零创建时加读 [`lark-doc-create-workflow.md`](references/style/lark-doc-create-workflow.md);编辑已有文档时加读 [`lark-doc-update.md`](references/lark-doc-update.md) 和 [`lark-doc-update-workflow.md`](references/style/lark-doc-update-workflow.md)
|
||||||
|
|
||||||
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
|
**未读完以上文件就执行相应操作会导致参数选择错误或格式错误。**
|
||||||
|
|
||||||
> **格式选择规则(全局):**
|
> **格式选择规则(全局):**
|
||||||
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML(可用 callout、grid、checkbox 等富 block)。
|
> - **创建 / 导入场景**(`docs +create`,或 `docs +update --command append/overwrite` 的整段写入):XML 和 Markdown 都可以。用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;否则默认 XML。
|
||||||
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
> - **精准编辑场景**(`docs +update` 的 `str_replace` / `block_insert_after` / `block_replace` / `block_delete` / `block_move_after` 等局部精修指令):优先使用 XML(`--doc-format xml`,即默认值)。XML 能稳定表达 block 结构和样式,局部精修更可控;不要因为 Markdown 更简单就自行切换。
|
||||||
|
|
||||||
## 快速决策
|
## 快速决策
|
||||||
@@ -39,9 +39,10 @@ lark-cli docs +update --doc "文档URL或token" --command append --content '<p>
|
|||||||
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
|
- 连续执行多个文档写操作时,必须按 [`lark-doc-update.md`](references/lark-doc-update.md) 的「Block ID 生命周期」判断旧 block ID 是否还能复用;`overwrite` / `block_replace` / `block_delete` 后不要复用受影响的旧 ID,插入 / 复制后要重新 fetch 才能拿到新 block ID
|
||||||
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
- 用户需要在文档内**创建、复制或移动**资源块(画板、电子表格、多维表格等)时,必须先读取 [`lark-doc-xml.md`](references/lark-doc-xml.md) 的「三、资源块」章节
|
||||||
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
|
- 写文档时,由内容和用户意图决定表达形式;流程、架构、路线图、关键指标等信息可以使用画板,但不要默认把重要信息都画板化
|
||||||
- 新增画板必须隔离到 SubAgent:简单图由 SubAgent 直接插入 `<whiteboard type="svg">完整 SVG</whiteboard>`,不读 `lark-whiteboard`;复杂图才由主 Agent 先建 `<whiteboard type="blank"></whiteboard>`,再启动 SubAgent 读取 `lark-whiteboard` 写入
|
- 新增或更新画板时,按 [`lark-doc-whiteboard.md`](references/lark-doc-whiteboard.md) 选型;Mermaid 可由主 Agent 直接插入,SVG / 复杂图 / 已有画板更新按其中流程隔离到 SubAgent
|
||||||
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
|
- 用户说"看一下文档里的图片/附件/素材""预览素材" → 用 `lark-cli docs +media-preview`
|
||||||
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
|
- 用户明确说"下载素材" → 用 `lark-cli docs +media-download`
|
||||||
|
- 用户想把文档回滚到某个 `revision_id` 或某一时刻 → 先读 [`lark-doc-history.md`](references/lark-doc-history.md),按其中流程操作
|
||||||
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
|
- 用户明确说"下载/更新/删除文档封面图" → 用 `lark-cli docs +resource-download/+resource-update/+resource-delete --type cover`
|
||||||
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
- `resource-*` 目前仅支持 Docx 封面资源;其他图片、附件或素材请走 `+media-*`
|
||||||
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
- 如果目标是画板/whiteboard/画板缩略图 → 只能用 `lark-cli docs +media-download --type whiteboard`(不要用 `+media-preview`)
|
||||||
@@ -69,6 +70,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli docs +<verb> [flags]`)
|
|||||||
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
|
| [`+create`](references/lark-doc-create.md) | Create a Lark document (XML / Markdown) |
|
||||||
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
|
| [`+fetch`](references/lark-doc-fetch.md) | Fetch Lark document content (XML / Markdown / im-markdown; `im-markdown` only after fetch for `lark-im`) |
|
||||||
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
|
| [`+update`](references/lark-doc-update.md) | Update a Lark document (str_replace / block_insert_after / block_replace / ...) |
|
||||||
|
| [`+history-list` / `+history-revert` / `+history-revert-status`](references/lark-doc-history.md) | List document history, revert to a `history_version_id`, and query revert task status |
|
||||||
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
|
| [`+media-insert`](references/lark-doc-media-insert.md) | Insert a local image or file at the end of a Lark document (4-step orchestration + auto-rollback). Prefer `--from-clipboard` when the image is already on the system clipboard (screenshots, copy from Feishu/browser); use `--file` only for on-disk sources. |
|
||||||
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
| [`+media-download`](references/lark-doc-media-download.md) | Download document media or whiteboard thumbnail (auto-detects extension) |
|
||||||
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
| [`+media-preview`](references/lark-doc-media-preview.md) | Preview document media file (auto-detects extension) |
|
||||||
|
|||||||
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
|
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
|
||||||
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
> 3. [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
|
||||||
>
|
>
|
||||||
> **未读完以上文件就生成内容会导致格式错误。**
|
> **未读完以上文件就生成内容会导致格式错误。**
|
||||||
|
|
||||||
从 XML(默认)或 Markdown 内容创建一个新的飞书云文档。
|
从 XML(默认)或 Markdown 内容创建一个新的飞书云文档。
|
||||||
|
|
||||||
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,支持 callout、grid、checkbox 等富 block 类型)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
|
> **⚠️ 格式选择规则:** 创建 / 导入场景下 XML 和 Markdown 都可以——用户提供 `.md` 本地文件、或明确说"导入 Markdown"时,直接用 Markdown;没有明确指示时默认 XML(表达能力更强,可承载更丰富的结构化内容)。不要在用户没要求的情况下主动从 XML 切到 Markdown,也不要在用户已给出 Markdown 时强行改成 XML。
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
@@ -72,8 +72,8 @@ lark-cli docs +create --doc-format markdown --title "项目计划" --content $'#
|
|||||||
|
|
||||||
## 参考
|
## 参考
|
||||||
|
|
||||||
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、并行执行策略)
|
- [`lark-doc-create-workflow.md`](style/lark-doc-create-workflow.md) — 从零创作工作流(Code-Act Loop、单 Agent 串行撰写)
|
||||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
|
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
|
||||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||||
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
||||||
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
|
- [`lark-doc-update.md`](lark-doc-update.md) — 更新文档
|
||||||
|
|||||||
107
skills/lark-doc/references/lark-doc-history.md
Normal file
107
skills/lark-doc/references/lark-doc-history.md
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
# docs history(历史版本与回滚)
|
||||||
|
|
||||||
|
用于查看 Docx 历史版本、按 `history_version_id` 回滚,以及查询回滚任务状态。
|
||||||
|
|
||||||
|
## 安全流程
|
||||||
|
|
||||||
|
1. 先用分页接口 `+history-list` 找到目标版本的 `history_version_id`。
|
||||||
|
2. 如果用户指定的是 `revision_id`,不要假设它唯一,也不要把 `revision_id` 直接传给 `+history-revert`。先拉一页并在 `entries[]` 中筛选 `revision_id` 相同的候选;如果未匹配到且 `has_more=true`,继续用 `page_token` 翻页;如果已匹配到候选,最多额外再拉一页补齐可能跨页的相邻候选。最终优先根据用户目标时间与 `edit_time` 的接近程度选择最合适的一条,取同一条的 `history_version_id`;如果没有目标时间,或多个候选无法可靠区分,再向用户展示候选版本(`history_version_id`、`revision_id`、`edit_time`、`name/description`)并确认后回滚。
|
||||||
|
3. 如果用户指定的是某一时刻但没有指定 `revision_id`,按 `entries[].edit_time` 匹配;优先选择不晚于目标时刻的最近一条历史记录,无法明确匹配时先向用户确认候选版本。
|
||||||
|
4. 再用 `+history-revert --history-version-id <history_version_id>` 发起回滚。默认最多等待 30 秒;如果返回 `status: running`,记录 `task_id`。
|
||||||
|
5. 用 `+history-revert-status` 轮询 `task_id`,直到状态不再是 `running`。
|
||||||
|
6. 回滚完成后,用 `docs +fetch` 读取文档确认内容。
|
||||||
|
|
||||||
|
## 按 revision_id 或时间点回滚
|
||||||
|
|
||||||
|
当用户说“回滚到 revision_id=42”“恢复到昨天下午 3 点的版本”这类需求时,流程是:
|
||||||
|
|
||||||
|
1. 执行 `docs +history-list --doc <doc>` 获取第一页历史记录;`+history-list` 是分页接口,只有 `has_more=true` 且还需要更多候选时才继续传 `--page-token` 翻页。
|
||||||
|
2. 如果用户给出 `revision_id`:先筛选当前页中 `entries[].revision_id == 用户给出的 revision_id`。如果未命中且 `has_more=true`,继续拉下一页;如果已经命中候选,最多额外再拉一页,补齐同一个 `revision_id` 可能跨页出现的相邻 `history_version_id`。若用户同时给出目标时间,在候选里选择 `edit_time` 与目标时间最接近的一条;若未给目标时间但候选只有一条,可直接使用;若多个候选无法可靠区分,不要自行取第一条,向用户展示候选并确认。
|
||||||
|
3. 如果用户只给出时间:用 `entries[].edit_time` 匹配,选择目标时刻之前最近的一条;如果用户表达的是“最接近某时刻”,则选择绝对时间差最小的一条。
|
||||||
|
4. 从最终匹配条目读取 `history_version_id`。`history_version_id` 对应服务端 `minor_history.version`,这是回滚接口需要的 ID。
|
||||||
|
5. 执行 `docs +history-revert --doc <doc> --history-version-id <history_version_id>`。
|
||||||
|
|
||||||
|
候选确认时使用类似格式:
|
||||||
|
|
||||||
|
```text
|
||||||
|
同一个 revision_id 命中多个历史版本,请确认要回滚哪一条:
|
||||||
|
- history_version_id=11 revision_id=42 edit_time=2026-06-22T12:24:45Z name=...
|
||||||
|
- history_version_id=12 revision_id=42 edit_time=2026-06-22T12:25:14Z name=...
|
||||||
|
```
|
||||||
|
|
||||||
|
## 命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 列出历史版本
|
||||||
|
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20
|
||||||
|
|
||||||
|
# 翻页
|
||||||
|
lark-cli docs +history-list --doc "<docx_url_or_token>" --page-size 20 --page-token "<page_token>"
|
||||||
|
|
||||||
|
# 回滚到指定 history_version_id(默认等待 30000ms)
|
||||||
|
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42
|
||||||
|
|
||||||
|
# 只发起任务,不等待
|
||||||
|
lark-cli docs +history-revert --doc "<docx_url_or_token>" --history-version-id 42 --wait-timeout-ms 0
|
||||||
|
|
||||||
|
# 查询回滚任务状态
|
||||||
|
lark-cli docs +history-revert-status --doc "<docx_url_or_token>" --task-id "<task_id>"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 参数
|
||||||
|
|
||||||
|
| 命令 | 参数 | 必填 | 说明 |
|
||||||
|
|-|-|-|-|
|
||||||
|
| `+history-list` | `--doc` | 是 | Docx URL/token,或可解析为 Docx 的 wiki URL |
|
||||||
|
| `+history-list` | `--page-size` | 否 | 返回条数,范围 `1-20`,默认 `20` |
|
||||||
|
| `+history-list` | `--page-token` | 否 | 上一页返回的 `page_token` |
|
||||||
|
| `+history-revert` | `--doc` | 是 | Docx URL/token,或可解析为 Docx 的 wiki URL |
|
||||||
|
| `+history-revert` | `--history-version-id` | 是 | `+history-list` 返回的 `history_version_id`,必须大于 0 |
|
||||||
|
| `+history-revert` | `--wait-timeout-ms` | 否 | 等待回滚完成的毫秒数,范围 `0-30000`,默认 `30000` |
|
||||||
|
| `+history-revert-status` | `--doc` | 是 | 同一个文档 |
|
||||||
|
| `+history-revert-status` | `--task-id` | 是 | `+history-revert` 返回的 `task_id` |
|
||||||
|
|
||||||
|
## 返回值要点
|
||||||
|
|
||||||
|
`+history-list` 返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"revision_id": 42,
|
||||||
|
"history_version_id": "11",
|
||||||
|
"edit_time": "1780000000",
|
||||||
|
"type": 1,
|
||||||
|
"name": "版本名",
|
||||||
|
"description": "版本说明",
|
||||||
|
"editor_ids": ["ou_xxx"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"has_more": true,
|
||||||
|
"page_token": "page_token"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`+history-revert` 返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"task_id": "task_xxx",
|
||||||
|
"status": "running",
|
||||||
|
"history_version_id": "11",
|
||||||
|
"poll_after_ms": 10000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`+history-revert-status` 返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "partial_failed",
|
||||||
|
"history_version_id": "11",
|
||||||
|
"failed_block_tokens": ["blk_xxx"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`status` 可能是 `running`、`done`、`partial_failed`、`failed`。当状态是 `partial_failed` 或 `failed` 时,优先检查 `failed_block_tokens`。
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
|
|
||||||
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
> **前置条件(MUST READ):** 生成文档内容前,必须先用 Read 工具读取以下文件,缺一不可:
|
||||||
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
> 1. [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规则(使用 Markdown 格式时改读 [`lark-doc-md.md`](lark-doc-md.md))
|
||||||
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 排版指南(元素选择、丰富度规则、颜色语义)
|
> 2. [`lark-doc-style.md`](style/lark-doc-style.md) — 写作原则(默认段落、按体裁、组件克制)
|
||||||
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
> 3. [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
|
||||||
>
|
>
|
||||||
> **未读完以上文件就生成内容会导致格式错误。**
|
> **未读完以上文件就生成内容会导致格式错误。**
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
|||||||
|
|
||||||
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
|
> **`docs +update` 不能直接编辑已有画板的内容。** 本命令只能**新增**画板块;要修改已有画板,先用 `docs +fetch` 取到 `<whiteboard token="...">`,再按 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md) 启动 SubAgent 读取 [`lark-whiteboard`](../../lark-whiteboard/SKILL.md) 并写入。
|
||||||
|
|
||||||
画板的语法选型与插入示例见 [`lark-doc-style.md`](style/lark-doc-style.md) 的「画板语法与插入」章节。
|
画板的语法选型与插入示例见 [`lark-doc-xml.md`](lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](lark-doc-whiteboard.md)。
|
||||||
|
|
||||||
## 最佳实践
|
## 最佳实践
|
||||||
|
|
||||||
@@ -252,8 +252,8 @@ lark-cli docs +update --doc "<doc_id>" --command str_replace \
|
|||||||
|
|
||||||
## 参考
|
## 参考
|
||||||
|
|
||||||
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、并行执行策略)
|
- [`lark-doc-update-workflow.md`](style/lark-doc-update-workflow.md) — 改写增强工作流(Code-Act Loop、单 Agent 串行改写)
|
||||||
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档样式指南(元素选择 + 丰富度规则 + 颜色语义)
|
- [`lark-doc-style.md`](style/lark-doc-style.md) — 文档写作原则(默认段落、按体裁、组件克制)
|
||||||
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
- [`lark-doc-xml.md`](lark-doc-xml.md) — XML 语法规范
|
||||||
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
- [`lark-doc-fetch.md`](lark-doc-fetch.md) — 获取文档
|
||||||
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档
|
- [`lark-doc-create.md`](lark-doc-create.md) — 创建文档
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
|||||||
|
|
||||||
`$URL` 可以是用户给出的 docx/wiki URL,也可以是可被 `docs +fetch` 解析的 token。
|
`$URL` 可以是用户给出的 docx/wiki URL,也可以是可被 `docs +fetch` 解析的 token。
|
||||||
|
|
||||||
|
## 统计范围
|
||||||
|
|
||||||
|
先判断用户要求的是**整篇文档**还是**局部内容**:
|
||||||
|
|
||||||
|
- 整篇文档的总字数 / 总字符数:按上方「调用方式」抓取 `full` 内容后统计。
|
||||||
|
- 本次新增 / 替换 / 改写片段的字数:优先统计拟写内容本身;内容已写入文档时,只 fetch 对应 block / range 后统计。不得用整篇文档字数对比局部目标。
|
||||||
|
|
||||||
如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
|
如需在自动化或回归验证中发现未覆盖块类型,追加严格参数:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -36,6 +43,15 @@ lark-cli docs +fetch --doc "$URL" --doc-format xml --detail full --format json \
|
|||||||
|
|
||||||
如果 `unknown_blocks` 或 `unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
|
如果 `unknown_blocks` 或 `unsupported_blocks` 非空,回复用户时要说明“已统计可提取文本,但存在未覆盖块,结果可能偏低”,并列出对应块类型。为空时可直接给出结果。
|
||||||
|
|
||||||
|
## 字数遵循校验
|
||||||
|
|
||||||
|
当用户给了明确字数要求(写 N 字 / x-y 字 / x 字左右 / 上下浮动)时执行;没有明确字数要求则跳过。字数必须按本文流程用脚本统计,不要自己估。
|
||||||
|
|
||||||
|
1. 先按「统计范围」确认统计对象,再把要求归一成目标区间:`>x`→`[x+1, +∞)`;`<y`→`(-∞, y-1]`;`x-y`→`[x, y]`;`x 字左右`→`[round(0.9x), round(1.1x)]`
|
||||||
|
2. 按统计对象选择对应输入并调用脚本统计实际字数,读取输出里的 `word_count`
|
||||||
|
3. 对比 `word_count` 与目标区间:区间内即通过;低于下限 → 补充**实质内容**(非注水);高于上限 → 删减冗余内容。改完重新统计
|
||||||
|
4. **最多 2 轮**。2 轮后仍不达标:停止,不得为达标而注水或删关键内容;如实汇报【目标区间 / 当前字数 / 差值与方向 / 已试 2 轮 / 未达原因】,**禁止谎称达标**
|
||||||
|
|
||||||
## 输出示例
|
## 输出示例
|
||||||
|
|
||||||
输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下:
|
输入正文等价于:`标题` + `一个苹果是 an apple。` 时,输出形态如下:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行:
|
通过自适应的 **Code-Act Loop** 驱动文档创作,而非固定模板式的工作流。每次任务都循环执行:
|
||||||
|
|
||||||
1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
|
1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
|
||||||
2. **Execute(执行)** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
|
2. **Execute(执行)** — 由主 Agent 自己运行 `lark-cli docs` 命令推进正文;仅画板渲染按需隔离到 SubAgent(见步骤三)
|
||||||
3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
|
3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
|
||||||
4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
|
4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
|
||||||
|
|
||||||
@@ -16,44 +16,32 @@
|
|||||||
|
|
||||||
## 典型 Code-Act Loop 流程
|
## 典型 Code-Act Loop 流程
|
||||||
|
|
||||||
### 步骤一:规划与初始创建(串行)
|
### 步骤一:规划与撰写(单 Agent 串行)
|
||||||
|
|
||||||
|
正文由主 Agent 串行维护,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效。
|
||||||
|
|
||||||
1. 分析用户需求:受众、目的、范围
|
1. 分析用户需求:受众、目的、范围
|
||||||
2. 设计大纲:根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式;不要默认套固定章节、固定开头或固定富 block 配比
|
2. 设计大纲:根据任务自然选择结构。可以是短文、纪要、FAQ、方案、报告、清单或其他形式;不要默认套固定章节、固定开头或固定富 block 配比
|
||||||
3. `docs +create` 创建文档。长文档可**只建骨架**:标题 + 各级标题 + 每节一句占位摘要;短文档可以一次写入完整内容
|
3. `docs +create` 创建并撰写:
|
||||||
- ⚠️ 创建较长文档时,**不要**一次性把完整章节内容塞进 `--content`。超长 `--content` 容易触发字符/参数限制。
|
- **短文档**:一次写入完整内容。使用 Markdown 时,避免同时传入 `--title` 和同名 `# 标题`
|
||||||
- 完整内容留到步骤二,由各 Agent 用 `block_insert_after --block-id <章节标题 block_id>` 分段写入。
|
- **长文档**:先建骨架(标题 + 各级标题),再由主 Agent **顺序逐节**用 `block_insert_after --block-id <章节标题 block_id>` 补全正文;写完一节再写下一节,始终带着已写内容的上下文,保证衔接、不重复
|
||||||
- ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理。
|
- ⚠️ 不要一次性把超长完整内容塞进 `--content`,容易触发字符/参数限制;长文按节分次写入
|
||||||
|
- ⚠️ 同一节内多次插入时,要锚到**上一个新插入的 block**(按 [`lark-doc-update.md`](../lark-doc-update.md) 的「Block ID 生命周期」),否则反复锚同一个标题会让段落顺序颠倒
|
||||||
|
- ⚠️ 若先建骨架写了占位摘要,补正文时**删除占位摘要**,不要留残渣
|
||||||
|
- ⚠️ **`@file` 路径限制**:`--content @file` 只接受当前工作目录下的相对路径,传绝对路径(如 `@/tmp/xxx.md`)会报 `unsafe file path`。需要落盘时,将文件写在 cwd 下,用完自行清理
|
||||||
|
|
||||||
### 步骤二:分段撰写(并行 Agent)
|
### 步骤二:整合审查与画板识别(串行)
|
||||||
|
|
||||||
4. Spawn Agent 并行撰写各章节。每个 Agent 需收到:
|
4. `docs +fetch --api-version v2 --detail with-ids` 获取文档,审查整体效果
|
||||||
- 文档 token、负责的章节范围、用户目标、目标读者和已有风格线索
|
5. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材;检查跨节有无重复、矛盾或断流。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题就地定向修正
|
||||||
- `lark-doc-xml.md` 和 `lark-doc-style.md` 的完整路径(Agent 须先读取)
|
6. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
|
||||||
- 使用 `block_insert_after --block-id <章节标题 block_id>` 写入对应章节内容
|
|
||||||
|
|
||||||
### 步骤三:整合审查与画板识别(串行)
|
### 步骤三:画板处理与润色
|
||||||
|
|
||||||
5. `docs +fetch --detail with-ids` 获取文档,审查整体效果
|
7. **优先处理步骤二识别出的画板需求**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
|
||||||
6. 评估内容是否满足用户目标:事实是否完整、结构是否清楚、语气是否匹配、是否保留必要素材
|
8. 由**主 Agent 自行润色**(不另起内容子 Agent,正文始终一人维护):文字密集且不易读时,优先拆段、加小标题或调整顺序——叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`);只有确实存在行列数据时才用 `<table>`。其余富 block 的取舍一律遵循 `lark-doc-style.md` 的写作原则,不主动堆叠。需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用。本地图片使用 `docs +media-insert` 插入
|
||||||
7. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断是否有段落适合用图表达。重要信息优先画板化,记录需要插图的章节、推荐画板类型、mermaid/SVG 路径和用于画图的源内容
|
|
||||||
|
|
||||||
### 步骤四:画板处理与润色(并行 Agent)
|
### 步骤四:专项校验
|
||||||
|
|
||||||
8. **优先处理步骤三识别出的画板需求**:
|
9. **字数门禁**:如果用户给出任何明确字数要求(如“700-800 字”“1000 字左右”“不少于 500 字”“控制在 800 字以内”),本步骤必须执行,不属于按需项。读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;未得到脚本统计结果前,不得向用户声明“符合字数要求”。若没有明确字数要求,则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现目标区间、`word_count` 和达标结论
|
||||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
10. **重复标题检查**:文档生成后,检查文档标题和正文第一个标题块是否重复;若重复,删除或改写正文第一个标题块,避免读者看到同一标题连续出现
|
||||||
9. Spawn 内容改写 Agent 定向润色:
|
|
||||||
- 文字密集且不易读时,优先拆段、改列表、增加小标题或调整顺序;只有确实存在行列数据、并列对比或强提醒信息时,才考虑 `<table>` / `<grid>` / `<callout>`
|
|
||||||
- 需要明显分隔的主题可补充 `<hr/>`,不强制章节间都使用
|
|
||||||
- 本地图片使用 `docs +media-insert` 插入
|
|
||||||
|
|
||||||
|
|
||||||
## Agent 子任务要求
|
|
||||||
|
|
||||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。
|
|
||||||
|
|
||||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
|
||||||
|
|
||||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
|
||||||
|
|
||||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
|
||||||
|
|||||||
@@ -1,86 +1,68 @@
|
|||||||
# 文档表达组件参考
|
# 飞书文档写作原则
|
||||||
|
|
||||||
本文件说明飞书文档可用的结构化表达方式,供模型在需要时选择。它不是固定模板,也不是强制排版规范。
|
写飞书文档,像一个该领域资深的人类作者那样写,而不是把内容"装配"成组件。
|
||||||
|
本文只讲"何时用、什么风格";具体标签 / 命令语法见 [`lark-doc-xml.md`](../lark-doc-xml.md)。
|
||||||
|
|
||||||
默认原则:优先理解用户目标、受众、素材形态和已有文档风格,由模型自主决定结构、语气和视觉呈现。只有当用户明确要求“美化、重排版、做成报告/方案/看起来更专业”等,或内容本身明显需要结构化承载时,才主动使用下列组件。
|
## 一、用户明确要求优先
|
||||||
|
|
||||||
## 一、核心原则
|
用户点名要某种格式——高亮块、分栏、列表、某编号体例、表格、画板、某模板、某已有文档的风格——**一律照用户的来,下面的"默认克制"全部让位**。用户给了样例或已有文档,就沿用它的结构与语气。
|
||||||
|
|
||||||
1. **服务内容,而非套模板**:先判断信息最自然的表达方式,再选择段落、列表、表格、分栏、画板等元素
|
## 二、默认写连贯段落
|
||||||
2. **尊重用户风格**:用户给出样例、语气、结构或已有文档时,优先沿用;没有要求时不强行使用固定开头、固定章节或固定视觉组件
|
|
||||||
3. **适度结构化**:结构化 block 用于降低理解成本,不为了“丰富”而堆叠
|
|
||||||
4. **保持一致但不过度统一**:同类信息可使用相近表达,但允许因内容差异采用不同形式
|
|
||||||
5. **图示服务理解**:流程、架构、对比、风险、路线图、指标趋势等内容在图示明显降低理解成本时,可使用画板表达
|
|
||||||
|
|
||||||
## 二、元素选择指南
|
用户没指定时,**默认是连贯段落**;其余按内容类型分流,别一律"少用结构",也别什么都升标题:
|
||||||
|
|
||||||
需要图表时,按类型选择插入方式:思维导图/时序图/类图/饼图/甘特图可用 `<whiteboard type="mermaid">` 直接内嵌;其他新图表可启动 SubAgent 插入 `<whiteboard type="svg">完整 SVG</whiteboard>`;只有编辑**已有**画板时才调用 **lark-whiteboard** skill。
|
| 内容 | 用什么 | ❌ 别 |
|
||||||
|
|---|---|---|
|
||||||
|
| 叙述、论证、分析、说明 | **连贯段落** | 拆成列举 |
|
||||||
|
| 真·行列数据(预算、指标、对比、排期、字段说明) | **表格** | 写成段落或把字段堆成一行 |
|
||||||
|
| 字段:值(主题、时长、负责人等,少量) | **加粗标签行**或一句话 | 每字段一个标题 |
|
||||||
|
| 方法 / 措施 + 每项一段描述 | **加粗引导句段落**(「**全程督导。**…」) | 每项升标题 |
|
||||||
|
| 任务清单 / 检查项 / 待办事项 | **`<checkbox>`** | 用普通列表替代可交互待办 |
|
||||||
|
| 纯短并列项(无描述,如材料清单) | 列表 | — |
|
||||||
|
| 章节(内容成块、需在目录导航) | 标题层级 | — |
|
||||||
|
|
||||||
| 场景 | 可选表达方式 |
|
- 判断标准:**去掉结构后能顺成段落,就用段落;成行成列的数据,就用表格。**
|
||||||
|--------------------------------------------|---------------------------------------|
|
- **红线一:标题层级只给"章节"。** "小标题 + 一两句话"的小项(字段、方法、要点)不该占标题层级——按上表降成标签行 / 加粗引导句段落(否则目录里全是没信息量的条目)。
|
||||||
| 少数需要视觉提醒的短句,如风险、限制、待确认事项或关键提醒 | 需要视觉提醒时可用 `<callout>`;普通结论、摘要或章节导语优先使用段落、列表、小标题或加粗 |
|
- **红线二:列举(「一是 / 二是」「第一 / 第二」「(1)(2)(3)」)只给真正并列的具体项,且别每节都用。**
|
||||||
| 方案对比 / 优劣势 / Before vs After | 简短对比可用段落、列表或 `<grid>`;维度较多且需要逐项比较时再考虑 `<table>` 或画板 |
|
- 「一是 / 二是」是党务列举的措辞——只用在列具体的**问题 / 措施**那一处;背景、现状、认识、分析、过渡、总结**一律成段**。
|
||||||
| 简短低风险对比 | `<grid>` 2 列分栏 |
|
- **整篇每段 / 每节都"一是 / 二是",和"每段一个 bullet"是同一个骨架化的错——不因为是党务就变对**(纯清单 / 台账类除外)。
|
||||||
| 需要按行列精确比较或查阅的数据,如指标、清单、字段说明、排期 | 可用 `<table>`;短要点、步骤、摘要或普通说明优先使用段落、列表或小标题 |
|
|
||||||
| 任务清单 / 检查项 | `<checkbox>` |
|
|
||||||
| 代码片段 | `<pre lang="x" caption="说明">` |
|
|
||||||
| 引用 / 公式 | `<blockquote>` / `<latex>` |
|
|
||||||
| 操作入口 / 跳转链接 | `<button>` / `<a type="url-preview">` |
|
|
||||||
| 流程图 / 时间线 / 示意图 / 自定义图形 / 架构图 / 数据图 / 思维导图等 | 画板图表 |
|
|
||||||
|
|
||||||
|
## 三、按体裁写
|
||||||
|
|
||||||
### 画板意图识别
|
- **公文 / 法律 / 学术 / 申报 / 项目方案等严肃正式提交物**:靠规范的标题层级、段落与编号体系表达;**默认不用高亮块、分栏**,要强调用加粗或规范小标题。
|
||||||
|
- **面向公众号、微信等外部平台粘贴 / 发布的内容**:不用飞书特有富 block(高亮块、分栏等),粘出去会丢样式 / 错乱;改用标准标题、段落、列表、引用。
|
||||||
|
- **一般文档**:以可读为先,不堆砌结构。
|
||||||
|
|
||||||
撰写或审查每个段落/章节时,**必须判断该内容是否适合用图表达**。满足以下任一特征时,应使用画板而非纯文本;如果该内容承载章节核心结论、关键决策或主要论据,即使结构较简单也优先画板化:
|
## 四、编号与层级
|
||||||
|
|
||||||
| 内容特征 | 信号词 / 模式 | 推荐画板类型 |
|
- **一套编号体例、全篇一致;最忌中文大层级与阿拉伯小数编号混用。**
|
||||||
|
- 公文 / 正式材料常用:「一、→(一)→ 1.→(1)」(中文大层级 + 阿拉伯细分层级)。
|
||||||
|
- 学术 / 技术 / 商业报告:「1 → 1.1 → 1.1.1」或「一、→(一)→ 1.」,**择一**。
|
||||||
|
- ⚠️ **「一、」只能配「(一)」;要用阿拉伯小数就从顶层全用「1 / 1.1」。绝不「一、」配「1.1 / 2.1」**——这是最常见的混用。
|
||||||
|
- **不混用**多套(别"第X部分"+"一、"+"1."混着来);**同级不跳号**;**不跳级**。
|
||||||
|
- **编号 / 标题层级只给"章节"**,不要为了凑齐体例把每个小项都编上「(一)」、升成标题(小项处理方式见上文「二、默认写连贯段落」)。
|
||||||
|
- 简单的 1.2.3 并列项用原生 `<ol><li seq="auto">…</li></ol>` 让飞书自动编号、自动对齐;「一、(一)」原生产不出,才手打成文字——此时用标题级别表达层次,**不靠手动缩进**、各级顶格(全角括号「()」叠手动缩进会视觉错位)。
|
||||||
|
|
||||||
|
## 五、飞书特有组件,克制使用
|
||||||
|
|
||||||
|
- **高亮块 `<callout>`**:很重的强提醒信号,**默认不用**;只给"不提醒就会出错 / 遗漏"的关键项,全文极少(0~1 个),不要每节导语 / 结论都做成高亮块。
|
||||||
|
- **分栏 `<grid>`**:仅左右信息量相当、确需并排对照的短内容;否则用段落或表格。
|
||||||
|
- **画板**:默认用文字,只在**图示明显比文字更易懂**(流程、架构、时间线、对比、占比等)或用户要求时才用。怎么插、用哪种类型见 [`lark-doc-xml.md`](../lark-doc-xml.md) 与 [`lark-doc-whiteboard.md`](../lark-doc-whiteboard.md)。
|
||||||
|
- **颜色**:默认朴素、不上色;需要时保持语义一致,按下表选择对应颜色,不为装饰上色。可用色见 [`lark-doc-xml.md`](../lark-doc-xml.md) 的「美化系统」。
|
||||||
|
|
||||||
|
| 语义 | 背景色 | 文字色 |
|
||||||
|-|-|-|
|
|-|-|-|
|
||||||
| 多步骤的操作流程或决策路径 | "先…然后…最后"、"步骤 1/2/3"、"如果…则…否则" | 流程图 / 泳道图 |
|
| 信息、说明 | `light-blue` | `blue` |
|
||||||
| 系统或模块间的依赖与交互 | "调用"、"依赖"、"上游/下游"、"请求→响应" | 架构图 |
|
| 成功、推荐 | `light-green` | `green` |
|
||||||
| 上下级或从属关系 | "汇报给"、"下属"、"隶属"、"团队结构" | 组织架构图 |
|
| 警告 / 错误 / 风险 | `light-red` | `red` |
|
||||||
| 时间线或阶段演进 | "Q1/Q2"、"里程碑"、"阶段一→阶段二"、日期序列 | 时间线 / 里程碑 |
|
| 注意、待确认 | `light-yellow` | `yellow` |
|
||||||
| 因果分析或问题归因 | "根因"、"原因"、"导致"、"影响因素" | 鱼骨图 |
|
| 中性、辅助 | `light-gray` | — |
|
||||||
| 两个及以上方案/对象的多维度对比 | "vs"、"方案 A/B"、"优劣"、"对比" | 对比图 |
|
|
||||||
| 层级递进或优先级排序 | "基础→进阶→高级"、"L1/L2/L3"、"核心→外围" | 金字塔图 |
|
|
||||||
| 数值趋势或周期变化 | 带数字的时间序列、"增长/下降"、百分比变化 | 折线图 / 柱状图 |
|
|
||||||
| 漏斗或转化率 | "转化率"、"漏斗"、"从…到…留存" | 漏斗图 |
|
|
||||||
| 发散或归纳的思维结构 | "要点"、"维度"、"分支"、多层嵌套列表 | 思维导图 |
|
|
||||||
| 循环或飞轮效应 | "正循环"、"飞轮"、"闭环"、"A 驱动 B 驱动 C" | 飞轮图 |
|
|
||||||
| 占比分布 | "占比"、"份额"、"分布"、百分比加总 ≈100% | 饼图 / 树状图 |
|
|
||||||
|
|
||||||
**判断规则:**
|
## 六、写完自检
|
||||||
- 重要信息能图示就图示;不要为了省步骤把关键流程、架构、对比、风险链路写成纯文本
|
|
||||||
- 低重要度、局部辅助信息才用 `<table>` / `<grid>` / `<callout>` 承载
|
|
||||||
- 确定需要插入哪些图表后,参照 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的方式,插入图表画板。
|
|
||||||
|
|
||||||
## 三、颜色语义
|
交付前快速回看:
|
||||||
|
- **叙述是否被列举化**:背景 / 现状 / 认识 / 分析 / 成效 / 过渡 / 总结等应成段;列举只用于同层级、可并列处理的信息,如问题、措施、步骤、任务或材料清单。若正文反复使用连续编号、项目符号或固定并列句式,导致内容缺少叙述,应把背景 / 认识 / 分析 / 过渡改写成有承接关系的段落(纯清单 / 台账类除外)。
|
||||||
如果使用颜色,建议保持语义一致;不需要颜色时可以保持朴素文本风格:
|
- **数据是否正确呈现**:成行成列的数据应使用表格呈现,不要写成段落,也不要用分隔符把多个字段硬串在一起。
|
||||||
|
- **标题是否滥用**:"小标题 + 一句话"的小项不要升成标题;应改成标签行、加粗引导句段落或普通段落。
|
||||||
| 语义 | emoji 前缀 | callout 背景色 | 文字色 |
|
- **编号是否统一**:全篇一套、不跳号、不跳级,尤其不要中文 + 阿拉伯混用(如「一、」配「1.1」)。
|
||||||
|-|-|-|-|
|
- **组件是否克制且保真**:高亮块 / 分栏 / 画板 / 颜色应符合体裁和用户要求;引用 / 图片 / 资源块必须保留。
|
||||||
| 信息、说明 | ℹ️ "说明:" | `light-blue` | `blue` |
|
|
||||||
| 成功、推荐 | ✅ "推荐:" | `light-green` | `green` |
|
|
||||||
| 警告 / 错误 / 风险 | ⚠️❌ | `light-red` | `red` |
|
|
||||||
| 注意、待确认 | ❗"注意:" | `light-yellow` | `yellow` |
|
|
||||||
| 中性、辅助 | — | `light-gray` | — |
|
|
||||||
|
|
||||||
- 表头可使用 `background-color="light-gray"`,也可以保持默认样式
|
|
||||||
- 关键指标如使用 `<span text-color="green/red">` 突出,建议同时用 ↑↓ 或 +/- 标注方向(色觉无障碍)
|
|
||||||
|
|
||||||
## 四、排版规范
|
|
||||||
|
|
||||||
- 标题层级、段落长度、列表嵌套和 Grid 列数应以可读性为准,避免过深层级和过宽分栏
|
|
||||||
- 文档开头可以是结论、背景、摘要、问题陈述、目录或直接正文,不强制使用 `<callout>`
|
|
||||||
|
|
||||||
## 五、质量自检
|
|
||||||
|
|
||||||
生成内容后可以从以下角度自检,但不要把这些项当作硬性比例或固定模板:
|
|
||||||
|
|
||||||
| 指标 | 自检问题 |
|
|
||||||
|-|-|
|
|
||||||
| 信息表达 | 当前结构是否符合用户目标,而不是套用固定报告模板? |
|
|
||||||
| 阅读负担 | 是否有段落过长、层级过深、表格过宽或组件过多的问题? |
|
|
||||||
| 风格匹配 | 是否延续了用户给定样例或已有文档风格? |
|
|
||||||
| 组件必要性 | callout、grid、table、whiteboard 等是否真的提升理解? |
|
|
||||||
| 保真度 | 改写时是否保留了原文事实、引用、图片、附件和资源块? |
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
## 核心方法论 — Code-Act Loop
|
## 核心方法论 — Code-Act Loop
|
||||||
通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行:
|
通过自适应的 **Code-Act Loop** 驱动文档改写,而非固定模板式的工作流。每次任务都循环执行:
|
||||||
1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
|
1. **Plan(规划)** — 根据用户目标和文档当前状态,评估下一步该做什么
|
||||||
2. **Execute(执行)** — 运行相应的 `lark-cli docs` 命令,或 **spawn** Agent 子任务并行推进
|
2. **Execute(执行)** — 由主 Agent 自己运行 `lark-cli docs` 命令推进改写;仅画板渲染按需隔离到 SubAgent(见步骤二)
|
||||||
3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
|
3. **Observe(观察)** — 检查命令输出,验证正确性,确认内容是否满足用户目标
|
||||||
4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
|
4. **Iterate(迭代)** — 如需调整,回到 Plan 继续循环
|
||||||
|
|
||||||
@@ -23,33 +23,26 @@
|
|||||||
- 需要精确跨节区间 → `docs +fetch --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
|
- 需要精确跨节区间 → `docs +fetch --scope range --start-block-id xxx --end-block-id yyy`(或 `--end-block-id -1` 读到末尾)
|
||||||
- 用户只给了模糊关键词 → `docs +fetch --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
|
- 用户只给了模糊关键词 → `docs +fetch --scope keyword --keyword xxx --context-before 1 --context-after 1 --detail with-ids`
|
||||||
- 用户明确要改整篇 → `docs +fetch --detail with-ids`
|
- 用户明确要改整篇 → `docs +fetch --detail with-ids`
|
||||||
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) "意图引导:选择正确的 --scope"
|
- 详见 [`lark-doc-fetch.md`](../lark-doc-fetch.md) 中「选 `--scope`(读取范围)」小节
|
||||||
2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解
|
2. 系统性评估:用户想改什么、现有文档风格是什么、哪些内容需要保留、哪些问题影响理解
|
||||||
3. **画板意图识别**:逐章节扫描,按 `lark-doc-style.md`「画板意图识别」表判断哪些段落的信息适合用图表达。重要信息优先画板化,记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
|
3. **画板识别**:逐章节扫描,判断是否有段落用图明显比文字更易懂(流程 / 架构 / 时间线 / 对比 / 占比等,见 `lark-doc-style.md` 的画板原则)。默认用文字,只有确需图示才记录需要插图的章节(block ID)、推荐画板类型、mermaid/SVG路径和源内容片段
|
||||||
4. 向用户简要说明改进计划(包含识别出的画板机会)
|
4. 向用户简要说明改进计划(包含识别出的画板机会)
|
||||||
|
|
||||||
### 步骤二:定向改写(并行 Agent)
|
### 步骤二:定向改写(单 Agent 串行)
|
||||||
|
|
||||||
5. **优先处理步骤一识别出的画板候选段落**:
|
5. **优先处理步骤一识别出的画板候选段落**:读取并按 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 选型和插入;正文本身不交给 SubAgent
|
||||||
参考 [lark-doc-whiteboard.md](../lark-doc-whiteboard.md)中的方式,插入图表画板。
|
6. 由主 Agent **顺序逐节**改写,**不按章节拆给并行 Agent**,避免上下文割裂、重复矛盾和全文级约束失效:
|
||||||
6. Spawn 内容改写 Agent 在不重叠的章节上并行改进,各 Agent 收到文档 token 和特定 block ID:
|
|
||||||
- 沿用或轻微调整已有文档风格,除非用户要求彻底重排版
|
- 沿用或轻微调整已有文档风格,除非用户要求彻底重排版
|
||||||
- 优先通过重写段落、调整标题、拆分列表或补充小标题提升可读性
|
- 优先通过重写段落、调整标题、补充小标题提升可读性;叙述内容保持成段,**不要默认改成列表**,只有确属并列要点 / 步骤才用列表(见 `lark-doc-style.md`)
|
||||||
- 富 block 是可选表达手段,不因固定比例而添加;画板类需求只走第 5 步
|
- 富 block 是可选表达手段,不因固定比例而添加,取舍遵循 `lark-doc-style.md` 的写作原则;画板类需求只走第 5 步
|
||||||
|
|
||||||
### 步骤三:验证(串行)
|
### 步骤三:验证(串行)
|
||||||
|
|
||||||
7. 获取更新后文档局部内容,检查是否符合用户目标和已有风格
|
7. 获取更新后文档局部内容,检查是否符合用户目标和已有风格
|
||||||
8. 检查是否满足用户目标并保留原有关键内容;如仍有明显问题则定向修正,向用户呈现结果
|
8. 检查是否满足用户目标并保留原有关键内容。再按 `lark-doc-style.md` 的「写完自检」快速核对,发现问题则定向修正
|
||||||
|
|
||||||
## Agent 子任务要求
|
### 步骤四:专项校验(按需执行)
|
||||||
|
|
||||||
内容改写 Agent 必须收到:文档 token、章节范围(标题/block ID)、`lark-doc-xml.md` 和 `lark-doc-style.md` 路径、用户目标/风格要求、具体的 `docs +update` command 和 `--block-id`。
|
9. 仅当用户预期需要校验字数时,才读取并执行 [`lark-doc-word-stat.md`](../lark-doc-word-stat.md) 的「字数遵循校验」;否则跳过本项,不读取该 workflow。若执行了专项校验,向用户呈现结果
|
||||||
|
|
||||||
Mermaid 图由主 Agent 直接插入 `<whiteboard type="mermaid">...</whiteboard>`,无需 SubAgent。
|
**上下文节省提示**:主 Agent 改某节时如需重新读取,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉当前章节,不要重复拉全文。
|
||||||
|
|
||||||
SVG SubAgent 必须收到:文档 token、插入位置(标题/block ID)、图表目标、源内容片段、`lark-doc-xml.md` 路径,以及[lark-doc-whiteboard.md](../lark-doc-whiteboard.md) 中的 "SVG 设计 Workflow" 指南。它只负责插入一个 `<whiteboard type="svg">...</whiteboard>`,不改其他正文,也不读取 `lark-whiteboard`。
|
|
||||||
|
|
||||||
已有画板更新 SubAgent 必须收到:board_token、图表目标、推荐画板类型、源内容片段、[`../../../lark-whiteboard/SKILL.md`](../../../lark-whiteboard/SKILL.md) 路径。它只负责写入画板,不改文档正文。
|
|
||||||
|
|
||||||
**上下文节省提示**:Agent 如需在自己负责的章节内重新读取内容,优先用 `docs +fetch --scope section --start-block-id <章节标题id>`(自动覆盖整节),或 `--scope range --start-block-id xxx --end-block-id yyy` 精确区间,只拉自己的章节,不要重复拉全文。
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ metadata:
|
|||||||
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable,第一步必须使用 `lark-cli drive +import --type bitable`。
|
||||||
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`。
|
||||||
- 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。
|
- 用户要把本地 `.pptx` 导入成飞书幻灯片,使用 `lark-cli drive +import --type slides`;当前 PPTX 导入上限是 500MB。
|
||||||
|
- 批量执行 `drive +import` 且目标是同一个位置(同一 `--folder-token`、默认根目录,或同一 `--target-token`)时,必须串行执行;不要并发导入到同一位置,服务端可能返回并发冲突错误。
|
||||||
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
|
- 用户要在 Drive 里上传、创建、读取、局部 patch 或覆盖更新**原生 `.md` 文件**(不是导入成 docx),切到 [`lark-markdown`](../lark-markdown/SKILL.md)。
|
||||||
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`。
|
- 用户要比较原生 `.md` 文件的**历史版本差异**,或比较远端 Markdown 与本地草稿,切到 [`lark-markdown`](../lark-markdown/SKILL.md) 的 `lark-cli markdown +diff`;需要版本号时先用 `drive +version-history`。
|
||||||
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。
|
- 用户要查看、下载、回滚或删除文件的**历史版本**,使用 `drive +version-history`、`drive +version-get`、`drive +version-revert`、`drive +version-delete`;这组命令同时支持 `--as user` 和 `--as bot`,自动化场景优先 `--as bot`。
|
||||||
@@ -102,6 +103,7 @@ lark-cli drive +inspect --url 'https://xxx.feishu.cn/wiki/wikcnXXX'
|
|||||||
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
| `not exist` | 使用了错误的 token | 检查 token 类型,wiki 链接必须先查询获取 `obj_token` |
|
||||||
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
| `permission denied` | 没有相关操作权限 | 引导用户检查当前身份对文档/文件是否有相应操作权限;如果需要,可以授予相应权限 |
|
||||||
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) |
|
| `invalid file_type` | file_type 参数错误 | 根据 `obj_type` 传入正确的 file_type(docx/doc/sheet/slides/bitable) |
|
||||||
|
| `232140101` / `232140100` / `233523001`(常见于 `drive +import` 的 `job_error_msg`) | 同一位置下存在并发导入 / 创建操作 | 批量导入到同一文件夹、根目录或同一 `--target-token` 时改为串行执行;每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突 |
|
||||||
|
|
||||||
### 权限能力入口
|
### 权限能力入口
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,13 @@
|
|||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> 当用户**未传 `--name`** 时,文档标题默认取源文件名(去掉扩展名)。在执行导入前,先友好提示用户:「当前未指定文档标题,默认将使用"xxx"作为标题。如果文件内容中也包含相同标题,导入后可能造成视觉重复。是否需要重命名?」让用户确认后再继续。
|
> 当用户**未传 `--name`** 时,文档标题默认取源文件名(去掉扩展名)。在执行导入前,先友好提示用户:「当前未指定文档标题,默认将使用"xxx"作为标题。如果文件内容中也包含相同标题,导入后可能造成视觉重复。是否需要重命名?」让用户确认后再继续。
|
||||||
|
|
||||||
|
## 批量导入串行规则
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> 批量执行 `drive +import` 且目标是同一个位置时,必须串行执行,不要并发发起导入任务。这里的“相同位置”包括同一个 `--folder-token`、都省略 `--folder-token` 导入到默认根目录,或使用同一个 `--target-token` 导入到已有 bitable。
|
||||||
|
>
|
||||||
|
> 如果在同一位置下并发导入,服务端可能返回并发冲突错误。看到错误信息或 `job_error_msg` 中包含 `232140101`、`232140100`、`233523001` 任一错误码时,按同位置并发操作处理:停止并发导入,改为串行处理失败项;每个失败项每次重试前等待几秒,总共最多重试 3 次;仍失败就停止并向用户报告冲突。
|
||||||
|
|
||||||
## 命令
|
## 命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -143,6 +150,7 @@ lark-cli drive +import --file ./README.md --type docx --dry-run
|
|||||||
- “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart,不代表所有格式都允许导入超过 20MB 的文件。
|
- “超过 20MB 自动切换分片上传”只表示上传链路会切到 multipart,不代表所有格式都允许导入超过 20MB 的文件。
|
||||||
|
|
||||||
- 若导入任务执行失败,会返回失败时的 `job_status` 及错误信息。
|
- 若导入任务执行失败,会返回失败时的 `job_status` 及错误信息。
|
||||||
|
- 若导入失败信息包含 `232140101`、`232140100`、`233523001`,通常表示同一位置下存在并发导入 / 创建操作;批量场景请改为串行执行,每个失败项每次重试前等待几秒,总共最多重试 3 次,仍失败就停止并报告冲突。
|
||||||
- 若内置轮询超时但任务仍在处理中,shortcut 会成功返回,并带上:
|
- 若内置轮询超时但任务仍在处理中,shortcut 会成功返回,并带上:
|
||||||
- `ready=false`
|
- `ready=false`
|
||||||
- `timed_out=true`
|
- `timed_out=true`
|
||||||
|
|||||||
@@ -23,6 +23,8 @@
|
|||||||
> 错误:`lark-cli drive +search 方案`
|
> 错误:`lark-cli drive +search 方案`
|
||||||
> `+search` 不接受位置参数;空 `--query` 或省略 `--query` 表示纯靠 filter 浏览(合法)。
|
> `+search` 不接受位置参数;空 `--query` 或省略 `--query` 表示纯靠 filter 浏览(合法)。
|
||||||
>
|
>
|
||||||
|
> **`--query` 最长 30 个字符**:按字符数(Unicode 码点)算,中文每字算 1 个,与 ASCII 同口径;超过 30 会被服务端拒绝(`99992402 field validation failed`,**是报错不是截断**)。长关键词必须先压缩成核心实体 + 主题词(如把整句问题压成「项目名 + 主题」再搜),不要把整句原问塞进 `--query`。
|
||||||
|
>
|
||||||
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
|
> **列表型请求不要硬塞关键词**:如果用户只是要求"我这月创建的所有文档"、"最近半年我编辑过的文档"、"按类型分类统计"这类范围浏览 / 汇总请求,且没有给出标题片段或业务关键词,应使用 `--query ""` 搭配 `--created-by-me`、`--mine`、`--created-*`、`--edited-*`、`--doc-types` 等过滤条件。不要把"查找"、"所有文档"、"最近更新过"、"按类型分类统计"这类动作词或统计意图放进 `--query`,否则会把本来应靠 filter 命中的结果过度收窄。
|
||||||
|
|
||||||
### 自然语言 → 命令映射速查
|
### 自然语言 → 命令映射速查
|
||||||
@@ -101,7 +103,7 @@ lark-cli drive +search --query 方案 --page-token '<PAGE_TOKEN>'
|
|||||||
|
|
||||||
| 参数 | 必填 | 说明 |
|
| 参数 | 必填 | 说明 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:`、`""`、`OR`、`-`)。空字符串或省略表示纯 filter 浏览 |
|
| `--query <text>` | 否 | 搜索关键词;支持服务端高级语法(`intitle:`、`""`、`OR`、`-`)。空字符串或省略表示纯 filter 浏览。**长度上限 30 个字符(按 Unicode 码点算,中文每字算 1 个,与 ASCII 同口径);超过 30 服务端直接报 `99992402 field validation failed`,不会截断** |
|
||||||
| `--page-size <n>` | 否 | 每页数量,默认 15,最大 20。超过 20 自动 clamp;非正数(≤0)回落 15;**非数字值直接返回 validation 错误** |
|
| `--page-size <n>` | 否 | 每页数量,默认 15,最大 20。超过 20 自动 clamp;非正数(≤0)回落 15;**非数字值直接返回 validation 错误** |
|
||||||
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
|
| `--page-token <token>` | 否 | 上一次响应里的 `page_token`,用于翻页 |
|
||||||
| `--format` | 否 | `json`(默认)/ `pretty` |
|
| `--format` | 否 | `json`(默认)/ `pretty` |
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ lark-cli minutes +speaker-replace \
|
|||||||
|
|
||||||
Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`;`--from-speaker-id` 只接受 `speaker_id`。
|
Agent 必须先 `lark-cli api GET .../speakerlist`,再 `+speaker-replace`;`--from-speaker-id` 只接受 `speaker_id`。
|
||||||
|
|
||||||
|
`+speaker-replace` **不会**自己请求 speakerlist:`--from-speaker-id` 的值会原样发给替换接口。整条链路只在 Agent 一开始查一次 speakerlist,务必传入上一步拿到的 `speaker_id`(不要传展示名,否则替换接口会返回 speaker-not-found)。
|
||||||
|
|
||||||
### 2. 新说话人必须是 open_id
|
### 2. 新说话人必须是 open_id
|
||||||
|
|
||||||
`--to-user-id` 仅支持 `ou_` 开头的 open_id,**不支持直接传姓名**;如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`。
|
`--to-user-id` 仅支持 `ou_` 开头的 open_id,**不支持直接传姓名**;如果用户只给了姓名,请先用 [lark-contact](../../lark-contact/SKILL.md) 把姓名解析成 `open_id`。
|
||||||
|
|||||||
@@ -146,6 +146,24 @@ lark-cli update
|
|||||||
|
|
||||||
**重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。
|
**重要**:始终使用 `lark-cli update` 更新,它会同时更新 CLI 和 AI Skills。
|
||||||
|
|
||||||
|
## JSON 输出契约
|
||||||
|
|
||||||
|
`--format json`(默认)下,成功与错误的信封结构不同:
|
||||||
|
|
||||||
|
成功信封写入 **stdout**(退出码 0):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
|
||||||
|
```
|
||||||
|
|
||||||
|
错误信封写入 **stderr**(退出码非 0):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
|
||||||
|
```
|
||||||
|
|
||||||
|
**判断成功必须用 `ok == true`(或进程退出码 0),不要用 `code == 0`**:成功信封没有顶层 `code` / `msg` 字段,`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。按 OpenAPI 老格式 `{"code": 0, "msg": "ok"}` 判断会把所有成功调用误判为失败;封装写入类命令(如 `task +create`)时尤其危险,误判会绕过幂等逻辑导致重复创建。
|
||||||
|
|
||||||
## 安全规则
|
## 安全规则
|
||||||
|
|
||||||
- **禁止输出密钥**(appSecret、accessToken)到终端明文。
|
- **禁止输出密钥**(appSecret、accessToken)到终端明文。
|
||||||
|
|||||||
@@ -46,7 +46,20 @@ lark-cli task +create --summary "Test Task" --dry-run
|
|||||||
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.
|
1. Confirm with the user: task summary, due date, assignee, and tasklist if necessary.
|
||||||
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting `.identities.user.openId` (from `auth status`) or `.data.user.open_id` (from `contact +get-user`), and then passing it to the `--assignee` parameter.
|
- **Crucial Rule for Assignee**: If the user explicitly or implicitly says "create a task for me" (给我创建一个任务), or "help me create a task" (帮我新建/创建一个任务), you MUST assign the task to the current logged-in user. You can get the current user's `open_id` by executing `lark-cli auth status` (it already outputs JSON by default, so do not add `--json`) or `lark-cli contact +get-user` first, extracting `.identities.user.openId` (from `auth status`) or `.data.user.open_id` (from `contact +get-user`), and then passing it to the `--assignee` parameter.
|
||||||
2. Execute `lark-cli task +create --summary "..." ...`
|
2. Execute `lark-cli task +create --summary "..." ...`
|
||||||
3. Report the result: task ID and summary.
|
3. Judge success by `ok == true` in the stdout JSON (the success envelope has no `code` field — do not test `code == 0`), then report the result: task ID (`data.guid`) and summary.
|
||||||
|
|
||||||
|
Example success response:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"identity": "user",
|
||||||
|
"data": {
|
||||||
|
"guid": "e297d3d0-4b60-4a5f-a4d4-xxxxxxxxxxxx",
|
||||||
|
"url": "https://applink.larkoffice.com/client/todo/detail?guid=e297d3d0-4b60-4a5f-a4d4-xxxxxxxxxxxx"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
> [!CAUTION]
|
> [!CAUTION]
|
||||||
> This is a **Write Operation** -- You must confirm the user's intent before executing.
|
> This is a **Write Operation** -- You must confirm the user's intent before executing.
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# Docs CLI E2E Coverage
|
# Docs CLI E2E Coverage
|
||||||
|
|
||||||
## Metrics
|
## Metrics
|
||||||
- Denominator: 8 leaf commands
|
- Denominator: 11 leaf commands
|
||||||
- Covered: 3
|
- Covered: 6
|
||||||
- Coverage: 37.5%
|
- Coverage: 54.5%
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`.
|
- TestDocs_CreateAndFetchWorkflow: proves `docs +create` and `docs +fetch`; key `t.Run(...)` proof points are `create as bot` and `fetch as bot`.
|
||||||
@@ -11,6 +11,8 @@
|
|||||||
- TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes.
|
- TestDocs_UpdateWorkflow: proves `docs +update` via `update-title-and-content as bot`, then re-fetches the same doc in `verify as bot` to assert persisted title/content changes.
|
||||||
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`.
|
- TestDocs_DryRunDefaultsToV2OpenAPI: proves `docs +create`, `docs +fetch`, and `docs +update` dry-run all emit `/open-apis/docs_ai/v1/...` requests without MCP or `--api-version` guidance; its fetch case asserts fetch sends the default `extra_param`, and its update case asserts `--reference-map` is sent as request body `reference_map`.
|
||||||
- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `<title>...</title>` tag to request body `content`.
|
- TestDocs_CreateTitleDryRunPrependsContent: proves `docs +create --title` dry-run prepends an escaped `<title>...</title>` tag to request body `content`.
|
||||||
|
- TestDocs_DryRunDefaultsToV2OpenAPI also proves `docs +history-list`, `docs +history-revert`, and `docs +history-revert-status` dry-run endpoint and query/body shapes.
|
||||||
|
- TestDocs_HistoryWorkflow proves the guarded live history flow (`LARK_DOC_HISTORY_E2E=1`): create, update, list prior revisions, revert, poll status when needed, and fetch to verify reverted content.
|
||||||
- Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here.
|
- Setup note: docs workflows create a Drive folder through `drive files create_folder` in `helpers_test.go`; that helper is external to the docs domain and is not counted here.
|
||||||
- Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration.
|
- Blocked area: media and search shortcuts still need deterministic fixtures and local file orchestration.
|
||||||
|
|
||||||
@@ -20,6 +22,9 @@
|
|||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
|
| ✓ | docs +create | shortcut | docs/helpers_test.go::createDocWithRetry; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/create as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/create; docs_update_dryrun_test.go::TestDocs_CreateTitleDryRunPrependsContent | `--parent-token`; `--doc-format markdown`; `--content`; `--title` | helper asserts returned doc id from `data.document.document_id`; dry-run asserts title is prepended into request body content |
|
||||||
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true` | |
|
| ✓ | docs +fetch | shortcut | docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflow/fetch as bot; docs_update_test.go::TestDocs_UpdateWorkflow/verify as bot; docs_create_fetch_test.go::TestDocs_CreateAndFetchWorkflowAsUser/fetch as user; docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/fetch | `--doc <docToken>`; `--doc-format markdown`; default `extra_param.enable_user_cite_reference_map=true` | |
|
||||||
|
| ✓ | docs +history-list | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history list; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--page-size`; `--page-token` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
|
||||||
|
| ✓ | docs +history-revert | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--history-version-id`; `--wait-timeout-ms` | live workflow gated by `LARK_DOC_HISTORY_E2E=1` |
|
||||||
|
| ✓ | docs +history-revert-status | shortcut | docs_update_dryrun_test.go::TestDocs_DryRunDefaultsToV2OpenAPI/history revert status; docs_history_workflow_test.go::TestDocs_HistoryWorkflow | `--doc`; `--task-id` | live workflow polls only when revert returns `running` |
|
||||||
| ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet |
|
| ✕ | docs +media-download | shortcut | | none | no media fixture workflow yet |
|
||||||
| ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions |
|
| ✕ | docs +media-insert | shortcut | | none | requires deterministic upload fixture and rollback assertions |
|
||||||
| ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture |
|
| ✕ | docs +media-preview | shortcut | | none | requires deterministic media fixture |
|
||||||
|
|||||||
137
tests/cli_e2e/docs/docs_history_workflow_test.go
Normal file
137
tests/cli_e2e/docs/docs_history_workflow_test.go
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||||
|
// SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
package docs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
clie2e "github.com/larksuite/cli/tests/cli_e2e"
|
||||||
|
"github.com/larksuite/cli/tests/cli_e2e/drive"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/tidwall/gjson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDocs_HistoryWorkflow(t *testing.T) {
|
||||||
|
if os.Getenv("LARK_DOC_HISTORY_E2E") != "1" {
|
||||||
|
t.Skip("set LARK_DOC_HISTORY_E2E=1 to run docs history live workflow")
|
||||||
|
}
|
||||||
|
clie2e.SkipWithoutUserToken(t)
|
||||||
|
|
||||||
|
parentT := t
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
suffix := clie2e.GenerateSuffix()
|
||||||
|
folderName := "lark-cli-e2e-docs-history-folder-" + suffix
|
||||||
|
docTitle := "lark-cli-e2e-docs-history-" + suffix
|
||||||
|
originalMarker := "original history marker " + suffix
|
||||||
|
updatedMarker := "updated history marker " + suffix
|
||||||
|
const defaultAs = "user"
|
||||||
|
|
||||||
|
folderToken := drive.CreateDriveFolder(t, parentT, ctx, folderName, defaultAs, "")
|
||||||
|
docToken := createDocWithRetry(t, parentT, ctx, folderToken, docTitle, originalMarker, defaultAs)
|
||||||
|
|
||||||
|
updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||||
|
Args: []string{
|
||||||
|
"docs", "+update",
|
||||||
|
"--doc", docToken,
|
||||||
|
"--command", "overwrite",
|
||||||
|
"--doc-format", "markdown",
|
||||||
|
"--content", "# " + docTitle + "\n\n" + updatedMarker,
|
||||||
|
},
|
||||||
|
DefaultAs: defaultAs,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
updateResult.AssertExitCode(t, 0)
|
||||||
|
updateResult.AssertStdoutStatus(t, true)
|
||||||
|
|
||||||
|
fetchUpdated, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||||
|
Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"},
|
||||||
|
DefaultAs: defaultAs,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
fetchUpdated.AssertExitCode(t, 0)
|
||||||
|
fetchUpdated.AssertStdoutStatus(t, true)
|
||||||
|
updatedContent := gjson.Get(fetchUpdated.Stdout, "data.document.content").String()
|
||||||
|
assert.Contains(t, updatedContent, updatedMarker)
|
||||||
|
currentRevision := gjson.Get(fetchUpdated.Stdout, "data.document.revision_id").Int()
|
||||||
|
require.Greater(t, currentRevision, int64(0), "stdout:\n%s", fetchUpdated.Stdout)
|
||||||
|
|
||||||
|
var revertHistoryVersionID string
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
listResult, listErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||||
|
Args: []string{
|
||||||
|
"docs", "+history-list",
|
||||||
|
"--doc", docToken,
|
||||||
|
"--page-size", "20",
|
||||||
|
},
|
||||||
|
DefaultAs: defaultAs,
|
||||||
|
})
|
||||||
|
require.NoError(t, listErr)
|
||||||
|
listResult.AssertExitCode(t, 0)
|
||||||
|
listResult.AssertStdoutStatus(t, true)
|
||||||
|
for _, entry := range gjson.Get(listResult.Stdout, "data.entries").Array() {
|
||||||
|
revisionID := entry.Get("revision_id").Int()
|
||||||
|
historyVersionID := entry.Get("history_version_id").String()
|
||||||
|
if revisionID > 0 && revisionID < currentRevision && historyVersionID != "" {
|
||||||
|
revertHistoryVersionID = historyVersionID
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}, 45*time.Second, 3*time.Second, "history list did not expose a prior revision")
|
||||||
|
require.NotEmpty(t, revertHistoryVersionID)
|
||||||
|
|
||||||
|
revertResult, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||||
|
Args: []string{
|
||||||
|
"docs", "+history-revert",
|
||||||
|
"--doc", docToken,
|
||||||
|
"--history-version-id", revertHistoryVersionID,
|
||||||
|
"--wait-timeout-ms", "30000",
|
||||||
|
},
|
||||||
|
DefaultAs: defaultAs,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
revertResult.AssertExitCode(t, 0)
|
||||||
|
revertResult.AssertStdoutStatus(t, true)
|
||||||
|
|
||||||
|
status := gjson.Get(revertResult.Stdout, "data.status").String()
|
||||||
|
taskID := gjson.Get(revertResult.Stdout, "data.task_id").String()
|
||||||
|
statusStdout := revertResult.Stdout
|
||||||
|
if status == "running" {
|
||||||
|
require.NotEmpty(t, taskID, "stdout:\n%s", revertResult.Stdout)
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
statusResult, statusErr := clie2e.RunCmd(ctx, clie2e.Request{
|
||||||
|
Args: []string{
|
||||||
|
"docs", "+history-revert-status",
|
||||||
|
"--doc", docToken,
|
||||||
|
"--task-id", taskID,
|
||||||
|
},
|
||||||
|
DefaultAs: defaultAs,
|
||||||
|
})
|
||||||
|
require.NoError(t, statusErr)
|
||||||
|
statusResult.AssertExitCode(t, 0)
|
||||||
|
statusResult.AssertStdoutStatus(t, true)
|
||||||
|
statusStdout = statusResult.Stdout
|
||||||
|
status = gjson.Get(statusResult.Stdout, "data.status").String()
|
||||||
|
return status != "" && status != "running"
|
||||||
|
}, 60*time.Second, 5*time.Second, "history revert task did not finish")
|
||||||
|
}
|
||||||
|
require.Equal(t, "done", status, "status stdout:\n%s", statusStdout)
|
||||||
|
|
||||||
|
fetchReverted, err := clie2e.RunCmd(ctx, clie2e.Request{
|
||||||
|
Args: []string{"docs", "+fetch", "--doc", docToken, "--doc-format", "markdown"},
|
||||||
|
DefaultAs: defaultAs,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
fetchReverted.AssertExitCode(t, 0)
|
||||||
|
fetchReverted.AssertStdoutStatus(t, true)
|
||||||
|
revertedContent := gjson.Get(fetchReverted.Stdout, "data.document.content").String()
|
||||||
|
assert.Contains(t, revertedContent, originalMarker)
|
||||||
|
assert.NotContains(t, revertedContent, updatedMarker)
|
||||||
|
}
|
||||||
@@ -26,7 +26,10 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
|||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
args []string
|
args []string
|
||||||
|
wantContains []string
|
||||||
wantURL string
|
wantURL string
|
||||||
|
wantParams map[string]any
|
||||||
|
wantBody map[string]any
|
||||||
wantExtraParam string
|
wantExtraParam string
|
||||||
wantRefLabel string
|
wantRefLabel string
|
||||||
}{
|
}{
|
||||||
@@ -37,7 +40,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
|||||||
"--content", "<title>Dry Run</title><p>hello</p>",
|
"--content", "<title>Dry Run</title><p>hello</p>",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
},
|
},
|
||||||
wantURL: "/open-apis/docs_ai/v1/documents",
|
wantContains: []string{"/open-apis/docs_ai/v1/documents"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "create api-version v1 compatibility",
|
name: "create api-version v1 compatibility",
|
||||||
@@ -47,7 +50,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
|||||||
"--content", "<title>Dry Run</title><p>hello</p>",
|
"--content", "<title>Dry Run</title><p>hello</p>",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
},
|
},
|
||||||
wantURL: "/open-apis/docs_ai/v1/documents",
|
wantContains: []string{"/open-apis/docs_ai/v1/documents"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "fetch",
|
name: "fetch",
|
||||||
@@ -56,7 +59,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
|||||||
"--doc", "doxcnDryRunE2E",
|
"--doc", "doxcnDryRunE2E",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
},
|
},
|
||||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch",
|
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/fetch"},
|
||||||
wantExtraParam: `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`,
|
wantExtraParam: `{"enable_user_cite_reference_map":true,"return_html5_block_data":true}`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -68,7 +71,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
|||||||
"--content", "<p>hello</p>",
|
"--content", "<p>hello</p>",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
},
|
},
|
||||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
|
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update reference-map",
|
name: "update reference-map",
|
||||||
@@ -80,7 +83,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
|||||||
"--reference-map", `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
"--reference-map", `{"widget":{"r1":{"label":"widget-ref-value"}}}`,
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
},
|
},
|
||||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
|
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
|
||||||
wantRefLabel: "widget-ref-value",
|
wantRefLabel: "widget-ref-value",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -92,7 +95,50 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
|||||||
"--block-id", "blkA,blkB,blkC",
|
"--block-id", "blkA,blkB,blkC",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
},
|
},
|
||||||
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E",
|
wantContains: []string{"/open-apis/docs_ai/v1/documents/doxcnDryRunE2E"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "history list",
|
||||||
|
args: []string{
|
||||||
|
"docs", "+history-list",
|
||||||
|
"--doc", "doxcnDryRunE2E",
|
||||||
|
"--page-size", "5",
|
||||||
|
"--page-token", "page_token_1",
|
||||||
|
"--dry-run",
|
||||||
|
},
|
||||||
|
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/histories",
|
||||||
|
wantParams: map[string]any{
|
||||||
|
"page_size": 5,
|
||||||
|
"page_token": "page_token_1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "history revert",
|
||||||
|
args: []string{
|
||||||
|
"docs", "+history-revert",
|
||||||
|
"--doc", "doxcnDryRunE2E",
|
||||||
|
"--history-version-id", "42",
|
||||||
|
"--wait-timeout-ms", "0",
|
||||||
|
"--dry-run",
|
||||||
|
},
|
||||||
|
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/history/revert",
|
||||||
|
wantBody: map[string]any{
|
||||||
|
"history_version_id": "42",
|
||||||
|
"wait_timeout_ms": 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "history revert status",
|
||||||
|
args: []string{
|
||||||
|
"docs", "+history-revert-status",
|
||||||
|
"--doc", "doxcnDryRunE2E",
|
||||||
|
"--task-id", "task_1",
|
||||||
|
"--dry-run",
|
||||||
|
},
|
||||||
|
wantURL: "/open-apis/docs_ai/v1/documents/doxcnDryRunE2E/history/revert_status",
|
||||||
|
wantParams: map[string]any{
|
||||||
|
"task_id": "task_1",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,10 +152,7 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
|||||||
result.AssertExitCode(t, 0)
|
result.AssertExitCode(t, 0)
|
||||||
|
|
||||||
combined := result.Stdout + "\n" + result.Stderr
|
combined := result.Stdout + "\n" + result.Stderr
|
||||||
for _, want := range []string{
|
for _, want := range append(tt.wantContains, "docs_ai/v1") {
|
||||||
tt.wantURL,
|
|
||||||
"docs_ai/v1",
|
|
||||||
} {
|
|
||||||
if !strings.Contains(combined, want) {
|
if !strings.Contains(combined, want) {
|
||||||
t.Fatalf("dry-run output missing %q\nstdout:\n%s\nstderr:\n%s", want, result.Stdout, result.Stderr)
|
t.Fatalf("dry-run output missing %q\nstdout:\n%s\nstderr:\n%s", want, result.Stdout, result.Stderr)
|
||||||
}
|
}
|
||||||
@@ -120,6 +163,15 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
|||||||
if strings.Contains(combined, "--api-version") {
|
if strings.Contains(combined, "--api-version") {
|
||||||
t.Fatalf("dry-run output should not ask for --api-version\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
t.Fatalf("dry-run output should not ask for --api-version\nstdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
|
||||||
}
|
}
|
||||||
|
if tt.wantURL != "" {
|
||||||
|
require.Equal(t, tt.wantURL, gjson.Get(result.Stdout, "api.0.url").String(), "stdout:\n%s", result.Stdout)
|
||||||
|
}
|
||||||
|
for key, want := range tt.wantParams {
|
||||||
|
assertDryRunField(t, result.Stdout, "api.0.params."+key, want)
|
||||||
|
}
|
||||||
|
for key, want := range tt.wantBody {
|
||||||
|
assertDryRunField(t, result.Stdout, "api.0.body."+key, want)
|
||||||
|
}
|
||||||
if tt.wantExtraParam != "" {
|
if tt.wantExtraParam != "" {
|
||||||
extraParam := gjson.Get(result.Stdout, "api.0.body.extra_param").String()
|
extraParam := gjson.Get(result.Stdout, "api.0.body.extra_param").String()
|
||||||
require.JSONEq(t, tt.wantExtraParam, extraParam, "stdout:\n%s", result.Stdout)
|
require.JSONEq(t, tt.wantExtraParam, extraParam, "stdout:\n%s", result.Stdout)
|
||||||
@@ -132,6 +184,21 @@ func TestDocs_DryRunDefaultsToV2OpenAPI(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertDryRunField(t *testing.T, stdout, path string, want any) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
got := gjson.Get(stdout, path)
|
||||||
|
require.True(t, got.Exists(), "%s missing in stdout:\n%s", path, stdout)
|
||||||
|
switch want := want.(type) {
|
||||||
|
case int:
|
||||||
|
require.Equal(t, int64(want), got.Int(), "%s in stdout:\n%s", path, stdout)
|
||||||
|
case string:
|
||||||
|
require.Equal(t, want, got.String(), "%s in stdout:\n%s", path, stdout)
|
||||||
|
default:
|
||||||
|
t.Fatalf("unsupported dry-run assertion type %T for %s", want, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
|
func TestDocs_CreateTitleDryRunPrependsContent(t *testing.T) {
|
||||||
// Fake creds are enough — dry-run short-circuits before any real API call.
|
// Fake creds are enough — dry-run short-circuits before any real API call.
|
||||||
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
t.Setenv("LARKSUITE_CLI_APP_ID", "app")
|
||||||
|
|||||||
@@ -63,29 +63,5 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
|
|||||||
assert.True(t, strings.Contains(output, "from_speaker_id"), "dry-run should contain from_speaker_id, got: %s", output)
|
assert.True(t, strings.Contains(output, "from_speaker_id"), "dry-run should contain from_speaker_id, got: %s", output)
|
||||||
assert.True(t, strings.Contains(output, "ENCRYPTED_TOKEN_ABC"), "dry-run should contain the encrypted speaker id, got: %s", output)
|
assert.True(t, strings.Contains(output, "ENCRYPTED_TOKEN_ABC"), "dry-run should contain the encrypted speaker id, got: %s", output)
|
||||||
assert.False(t, strings.Contains(output, "from_user_id"), "dry-run should not contain from_user_id when from-speaker-id is set, got: %s", output)
|
assert.False(t, strings.Contains(output, "from_user_id"), "dry-run should not contain from_user_id when from-speaker-id is set, got: %s", output)
|
||||||
}
|
assert.False(t, strings.Contains(output, "/transcript/speakerlist"), "+speaker-replace should never fetch speakerlist, got: %s", output)
|
||||||
|
|
||||||
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) {
|
|
||||||
setDryRunConfigEnv(t)
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
t.Cleanup(cancel)
|
|
||||||
|
|
||||||
result, err := clie2e.RunCmd(ctx, clie2e.Request{
|
|
||||||
Args: []string{
|
|
||||||
"minutes", "+speaker-replace",
|
|
||||||
"--minute-token", "obcnexampleminute",
|
|
||||||
"--from-speaker-id", "说话人1",
|
|
||||||
"--to-user-id", "ou_new_speaker",
|
|
||||||
"--dry-run",
|
|
||||||
},
|
|
||||||
DefaultAs: "user",
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
result.AssertExitCode(t, 0)
|
|
||||||
|
|
||||||
output := result.Stdout
|
|
||||||
assert.True(t, strings.Contains(output, "GET"), "dry-run should contain GET for internal speaker list, got: %s", output)
|
|
||||||
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speakerlist"), "dry-run should contain speakerlist API path, got: %s", output)
|
|
||||||
assert.True(t, strings.Contains(output, "PUT"), "dry-run should contain PUT method, got: %s", output)
|
|
||||||
assert.True(t, strings.Contains(output, "/open-apis/minutes/v1/minutes/obcnexampleminute/transcript/speaker"), "dry-run should contain speaker replace path, got: %s", output)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user