Compare commits

..

1 Commits

Author SHA1 Message Date
zhanghuanxu
f8d2ab1ee2 docs(slides): sync chart gradient fields into slides xsd references
Mirror ai_xsd MR !56 (chart format fields) into the slides skill
reference bundle: add ChartGradient* types and split ChartLineType /
ChartAreaType into Global/Series variants in slides_xml_schema_definition.xml,
and document the series-only fillGradient/strokeGradient contract plus
chartBackground/chartBorder default-value adjustments in xml-format-guide.md
so authors do not attach gradients on the unconsumed chartStyle global layer.
2026-07-16 20:16:06 +08:00
15 changed files with 275 additions and 449 deletions

View File

@@ -2,27 +2,6 @@
All notable changes to this project will be documented in this file.
## [v1.0.71] - 2026-07-16
### Features
- add wiki move-to-drive shortcut (#1869)
- **apps**: add role management shortcuts (#1881)
- **drive**: add secure label support and clarify comment location API (#1913)
### Bug Fixes
- **base**: improve dashboard shortcut guidance (#1787)
### Documentation
- **apps**: add platform SQL authoring guide to the db-execute skill (#1912)
### Misc
- add L4 plugin-integration and sidecar-integration CI jobs (#1840)
- **drive**: optimize drive +delete workflow (#1909)
## [v1.0.70] - 2026-07-15
### Features
@@ -1527,7 +1506,6 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.71]: https://github.com/larksuite/cli/releases/tag/v1.0.71
[v1.0.70]: https://github.com/larksuite/cli/releases/tag/v1.0.70
[v1.0.69]: https://github.com/larksuite/cli/releases/tag/v1.0.69
[v1.0.68]: https://github.com/larksuite/cli/releases/tag/v1.0.68

View File

@@ -1,6 +1,6 @@
{
"name": "@larksuite/cli",
"version": "1.0.71",
"version": "1.0.70",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -32,15 +32,14 @@ type driveDeleteSpec struct {
FileType string
}
// DriveDelete deletes a Drive file or folder with async=true. When the response
// includes a task_id, it performs a bounded task_check poll before returning a
// resume command for unfinished tasks.
// DriveDelete deletes a Drive file or folder and handles the async task
// polling required by folder deletes.
var DriveDelete = common.Shortcut{
Service: "drive",
Command: "+delete",
Description: "Delete a file or folder in Drive",
Risk: "high-risk-write",
Scopes: []string{"space:document:delete", "drive:drive.metadata:readonly"},
Scopes: []string{"space:document:delete"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "file or folder token to delete", Required: true},
@@ -64,11 +63,13 @@ var DriveDelete = common.Shortcut{
dry.DELETE("/open-apis/drive/v1/files/:file_token").
Desc("[1] Delete file/folder").
Set("file_token", spec.FileToken).
Params(driveDeleteParams(spec))
Params(map[string]interface{}{"type": spec.FileType})
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[2] Poll async delete task status when task_id is returned").
Params(driveTaskCheckParams("<task_id>"))
if spec.FileType == "folder" {
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[2] Poll async task status (for folder delete)").
Params(driveTaskCheckParams("<task_id>"))
}
return dry
},
@@ -83,59 +84,56 @@ var DriveDelete = common.Shortcut{
data, err := runtime.CallAPITyped(
"DELETE",
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)),
driveDeleteParams(spec),
map[string]interface{}{"type": spec.FileType},
nil,
)
if err != nil {
return err
}
taskID := common.GetString(data, "task_id")
if taskID == "" {
runtime.Out(map[string]interface{}{
"deleted": true,
if spec.FileType == "folder" {
taskID := common.GetString(data, "task_id")
if taskID == "" {
return errs.NewInternalError(errs.SubtypeInvalidResponse, "delete folder returned no task_id")
}
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete is async, polling task %s...\n", taskID)
status, ready, err := pollDriveTaskCheck(runtime, taskID)
if err != nil {
return err
}
out := map[string]interface{}{
"task_id": taskID,
"status": status.StatusLabel(),
"file_token": spec.FileToken,
"type": spec.FileType,
}, nil)
"ready": ready,
}
if ready {
out["deleted"] = true
}
if !ready {
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
runtime.Out(out, nil)
return nil
}
fmt.Fprintf(runtime.IO().ErrOut, "Delete is async, polling task %s...\n", taskID)
status, ready, err := pollDriveTaskCheck(runtime, taskID)
if err != nil {
return err
}
out := map[string]interface{}{
"task_id": taskID,
"status": status.StatusLabel(),
runtime.Out(map[string]interface{}{
"deleted": true,
"file_token": spec.FileToken,
"type": spec.FileType,
"ready": ready,
}
if ready {
out["deleted"] = true
}
if !ready {
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
fmt.Fprintf(runtime.IO().ErrOut, "Delete task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
runtime.Out(out, nil)
}, nil)
return nil
},
}
func driveDeleteParams(spec driveDeleteSpec) map[string]interface{} {
return map[string]interface{}{
"type": spec.FileType,
"async": true,
}
}
func validateDriveDeleteSpec(spec driveDeleteSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--file-token")

View File

@@ -7,7 +7,6 @@ import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
@@ -33,16 +32,16 @@ func TestValidateDriveDeleteSpecRejectsWiki(t *testing.T) {
}
}
func TestDriveDeleteDryRunIncludesAsyncAndTaskCheckParams(t *testing.T) {
func TestDriveDeleteDryRunFolderIncludesTaskCheckParams(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +delete"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
if err := cmd.Flags().Set("file-token", "docx_src"); err != nil {
if err := cmd.Flags().Set("file-token", "fld_src"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", "docx"); err != nil {
if err := cmd.Flags().Set("type", "folder"); err != nil {
t.Fatalf("set --type: %v", err)
}
@@ -72,36 +71,14 @@ func TestDriveDeleteDryRunIncludesAsyncAndTaskCheckParams(t *testing.T) {
if got.API[0].Method != "DELETE" {
t.Fatalf("first method = %q, want DELETE", got.API[0].Method)
}
if got.API[0].Params["type"] != "docx" {
if got.API[0].Params["type"] != "folder" {
t.Fatalf("delete params = %#v", got.API[0].Params)
}
if got.API[0].Params["async"] != true {
t.Fatalf("delete params = %#v, want async=true", got.API[0].Params)
}
if got.API[1].Params["task_id"] != "<task_id>" {
t.Fatalf("task check params = %#v", got.API[1].Params)
}
}
func TestDriveDeleteScopesIncludeTaskCheckReadScope(t *testing.T) {
t.Parallel()
wantScopes := map[string]bool{
"space:document:delete": false,
"drive:drive.metadata:readonly": false,
}
for _, scope := range DriveDelete.Scopes {
if _, ok := wantScopes[scope]; ok {
wantScopes[scope] = true
}
}
for scope, seen := range wantScopes {
if !seen {
t.Fatalf("DriveDelete.Scopes missing %q: %#v", scope, DriveDelete.Scopes)
}
}
}
func TestDriveDeleteRequiresYes(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
@@ -120,63 +97,6 @@ func TestDriveDeleteRequiresYes(t *testing.T) {
}
func TestDriveDeleteFileSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/file_token_test",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_file_123"},
},
OnMatch: func(req *http.Request) {
query := req.URL.Query()
if got := query.Get("type"); got != "file" {
t.Errorf("delete query type=%q, want file", got)
}
if got := query.Get("async"); got != "true" {
t.Errorf("delete query async=%q, want true", got)
}
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
},
OnMatch: func(req *http.Request) {
if got := req.URL.Query().Get("task_id"); got != "task_file_123" {
t.Errorf("task_check task_id=%q, want task_file_123", got)
}
},
})
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", "file_token_test",
"--type", "file",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_file_123"`)) {
t.Fatalf("stdout missing task_id: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"deleted": true`)) {
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
t.Fatalf("stdout missing ready=true: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"file_token": "file_token_test"`)) {
t.Fatalf("stdout missing file token: %s", stdout.String())
}
}
func TestDriveDeleteWithoutTaskIDFallsBackToSyncSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
@@ -197,33 +117,23 @@ func TestDriveDeleteWithoutTaskIDFallsBackToSyncSuccess(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, needle := range []string{
`"deleted": true`,
`"file_token": "file_token_test"`,
`"type": "file"`,
} {
if !bytes.Contains(stdout.Bytes(), []byte(needle)) {
t.Fatalf("stdout missing %q: %s", needle, stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"deleted": true`)) {
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
}
if bytes.Contains(stdout.Bytes(), []byte(`"task_id"`)) {
t.Fatalf("stdout should not include task_id for sync success fallback: %s", stdout.String())
if !bytes.Contains(stdout.Bytes(), []byte(`"file_token": "file_token_test"`)) {
t.Fatalf("stdout missing file token: %s", stdout.String())
}
}
func TestDriveDeleteTaskCheckOutcomes(t *testing.T) {
func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
tests := []struct {
name string
fileType string
fileToken string
taskCheckBody map[string]interface{}
wantErrContains string
wantStdout []string
}{
{
name: "docx success",
fileType: "docx",
fileToken: "docx_src",
name: "success",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
@@ -235,9 +145,7 @@ func TestDriveDeleteTaskCheckOutcomes(t *testing.T) {
},
},
{
name: "folder timeout",
fileType: "folder",
fileToken: "fld_src",
name: "timeout",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "process"},
@@ -249,19 +157,15 @@ func TestDriveDeleteTaskCheckOutcomes(t *testing.T) {
},
},
{
name: "folder failed",
fileType: "folder",
fileToken: "fld_src",
name: "failed",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "fail"},
},
wantErrContains: "drive task failed",
wantErrContains: "folder task failed",
},
{
name: "docx task_check error",
fileType: "docx",
fileToken: "docx_src",
name: "task_check error",
taskCheckBody: map[string]interface{}{
"code": 1061001,
"msg": "internal error",
@@ -275,7 +179,7 @@ func TestDriveDeleteTaskCheckOutcomes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/" + tt.fileToken,
URL: "/open-apis/drive/v1/files/fld_src",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_123"},
@@ -291,8 +195,8 @@ func TestDriveDeleteTaskCheckOutcomes(t *testing.T) {
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", tt.fileToken,
"--type", tt.fileType,
"--file-token", "fld_src",
"--type", "folder",
"--yes",
"--as", "bot",
}, f, stdout)
@@ -318,66 +222,3 @@ func TestDriveDeleteTaskCheckOutcomes(t *testing.T) {
})
}
}
func TestDriveDeleteTimedOutTaskCanBeResumedWithTaskResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/fld_token_test",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_resume_123"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "process"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
},
})
withSingleDriveTaskCheckPoll(t)
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", "fld_token_test",
"--type", "folder",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected delete error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
t.Fatalf("stdout missing ready=false: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_resume_123 --as bot"`)) {
t.Fatalf("stdout missing next_command: %s", stdout.String())
}
err = mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "task_check",
"--task-id", "task_resume_123",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected task_result error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_resume_123"`)) {
t.Fatalf("task_result stdout missing task_id: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
t.Fatalf("task_result stdout missing ready=true: %s", stdout.String())
}
}

View File

@@ -61,7 +61,7 @@ func validateDriveMoveSpec(spec driveMoveSpec) error {
}
// driveTaskCheckStatus represents the status payload returned by
// /drive/v1/files/task_check for async Drive move/delete operations.
// /drive/v1/files/task_check for async folder move/delete operations.
type driveTaskCheckStatus struct {
TaskID string
Status string
@@ -74,7 +74,7 @@ func (s driveTaskCheckStatus) Ready() bool {
func (s driveTaskCheckStatus) Failed() bool {
status := strings.TrimSpace(s.Status)
// The shared task_check endpoint is reused by multiple async flows. Some
// backends return "failed", while delete can return the shorter
// backends return "failed", while folder delete can return the shorter
// terminal state "fail".
return strings.EqualFold(status, "failed") || strings.EqualFold(status, "fail")
}
@@ -106,7 +106,7 @@ func driveTaskCheckParams(taskID string) map[string]interface{} {
}
// getDriveTaskCheckStatus fetches and validates the current state of an async
// Drive move or delete task.
// folder move or delete task.
func getDriveTaskCheckStatus(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return driveTaskCheckStatus{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id")
@@ -159,11 +159,11 @@ func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTas
// Success and failure are terminal backend states. Any other value is kept
// as pending so the caller can decide whether to continue or resume later.
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Drive task completed successfully.\n")
fmt.Fprintf(runtime.IO().ErrOut, "Folder task completed successfully.\n")
return status, true, nil
}
if status.Failed() {
return status, false, errs.NewAPIError(errs.SubtypeServerError, "drive task failed")
return status, false, errs.NewAPIError(errs.SubtypeServerError, "folder task failed")
}
}

View File

@@ -23,7 +23,7 @@ const (
)
// DriveTaskResult exposes a unified read path for the async task types produced
// by Drive import, export, file/folder move/delete, wiki move, wiki move-to-drive,
// by Drive import, export, folder move/delete, wiki move, wiki move-to-drive,
// and wiki delete flows.
var DriveTaskResult = common.Shortcut{
Service: "drive",
@@ -106,7 +106,7 @@ var DriveTaskResult = common.Shortcut{
Params(map[string]interface{}{"token": fileToken})
case "task_check":
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[1] Query Drive file/folder move/delete task status").
Desc("[1] Query move/delete folder task status").
Params(driveTaskCheckParams(taskID))
case "wiki_move":
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
@@ -225,7 +225,7 @@ func queryExportTask(runtime *common.RuntimeContext, ticket, fileToken string) (
}, nil
}
// queryTaskCheck returns the normalized status of a Drive file/folder move/delete task.
// queryTaskCheck returns the normalized status of a folder move/delete task.
func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
status, err := getDriveTaskCheckStatus(runtime, taskID)
if err != nil {

View File

@@ -1,7 +1,7 @@
---
name: lark-drive
version: 1.0.0
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本、飞书文档密级标签secure labels和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责文档内容编辑走 lark-doc、表格/Base 表内数据操作(走 lark-sheets/lark-base、知识空间节点/成员管理(走 lark-wiki、原生 Markdown 文件读写/patch/diff走 lark-markdown。"
description: "飞书云空间(云盘/云存储):管理 Drive 文件和文件夹,包含上传/下载、创建文件夹、复制/移动/删除、查看元数据、评论/权限/订阅、标题、版本和本地文件导入。用户需要整理云盘目录、处理云空间资源 URL/token、判断链接类型/真实 token/标题,或导入 Word/Markdown/Excel/CSV/PPTX/.base 为 docx/sheet/bitable/slides 时使用doubao.com 云空间 URL/token 也按资源路径和 token 路由,不回退 WebFetch。不负责文档内容编辑走 lark-doc、表格/Base 表内数据操作(走 lark-sheets/lark-base、知识空间节点/成员管理(走 lark-wiki、原生 Markdown 文件读写/patch/diff走 lark-markdown。"
metadata:
requires:
bins: ["lark-cli"]
@@ -24,8 +24,7 @@ metadata:
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `lark-cli drive files copy`。先用 `lark-cli schema drive.files.copy --format json` 确认参数;如果来源是 wiki URL/token先用 `lark-cli drive +inspect` 获取底层 `token``type`,不要把 wiki token 直接当 `file_token``params.file_token` 传源文档 token`data.folder_token` 传目标文件夹 token`data.name` 传副本名称,`data.type` 传源文件类型(如 `docx` / `sheet` / `bitable` / `slides`)。示例:`lark-cli drive files copy --params '{"file_token":"<DOC_TOKEN>"}' --data '{"folder_token":"<FOLDER_TOKEN>","name":"<COPY_NAME>","type":"docx"}'`。如返回 `confirmation_required`,按 `lark-shared` 高风险审批协议向用户确认后,在原命令末尾追加 `--yes` 重试。
- 用户要**识别飞书 / doubao 云空间 URL 的类型和 token**时,可以先按 URL 路径形态做轻量判断;当路径已明确指向 docx / sheet / bitable / slides / file / folder 等资源时,可直接提取对应 token/type。传入 wiki URL、需要识别标题或 canonical URL、URL/token 有歧义,或后续操作依赖底层真实资源时,再使用 `lark-cli drive +inspect --url '<url>'` 进行识别;具体用法、失败处理和边界见 [`references/lark-drive-inspect.md`](references/lark-drive-inspect.md)。
- 高风险写操作删除、公开权限修改、owner 转移、版本删除/回滚、批量移动/覆盖/同步)必须同时满足三个条件才执行:目标已解析为该操作可直接使用的执行对象,执行细节已明确到可直接调用命令(例如删除的 file-token/type、公开权限修改的共享范围、owner 转移的目标 owner、版本删除/回滚的 version id、移动/覆盖/同步的目标位置和冲突策略),且用户在本轮明确确认执行这些具体目标和执行细节。用户只说“删除没用的文件”“开放/共享给大家”“改成开放”“覆盖/移动这些”只表示目标状态;先只读发现并列出候选、权限档位或执行方案,停止等待用户确认。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要为指定飞书文档**设置 / 修改密级标签secure label**,或查询当前用户可用的密级标签,直接读取 [`references/lark-drive-secure-label.md`](references/lark-drive-secure-label.md);这是 Drive 文件治理能力。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、owner 转移**,或要权限风险报告、收紧权限、申请查看 / 编辑权限、转移 / 批量转移 owner”必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`permission_governance`](references/lark-drive-workflow-permission-governance.md) workflow。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`references/lark-drive-workflow.md`](references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](references/lark-drive-workflow-knowledge-organize.md) workflow。默认只生成方案创建目录、移动资源、申请权限都必须单独确认。
- 用户要**搜文档 / Wiki / 电子表格 / 多维表格 / 云空间(云盘/云存储)对象**,优先使用 `lark-cli drive +search`。自然语言里"最近我编辑过的"、"我创建的"(→ `--created-by-me`,原始创建者语义)、"我负责/owner 的"(→ `--mine`owner 语义)、"最近一周我打开过的 xxx"、"某人 owner 的 docx" 等直接映射到扁平 flag避免手写嵌套 JSON。
- 用户要**获取文档评论列表**时,优先使用 `lark-cli drive +list-comments --url '<url>'`,不要优先手写 `drive file.comments list`;支持妙搭 apps 的 `/page/<token>` URL具体使用方式先阅读 [`references/lark-drive-list-comments.md`](references/lark-drive-list-comments.md)。

View File

@@ -20,20 +20,16 @@
若缺少任一条件,使用 `drive +search``drive +inspect` 或只读 API 收集候选并回复待确认清单启发式规则打开时间、标题模式、owner、文件类型等只能作为候选筛选依据不能升级为删除确认。执行 `drive +delete` 时必须使用解析后的 `--file-token``--type`
## 批量删除建议
批量删除文件或文件夹时,建议逐个串行处理,不要并发执行删除命令,并发删除可能触发服务端加锁或冲突,导致部分删除失败;这类失败通常需要等待后对单个失败项重试。
## 命令
```bash
# 删除普通文件(异步操作,会自动有限轮询任务状态)
# 删除普通文件
lark-cli drive +delete \
--file-token <FILE_TOKEN> \
--type file \
--yes
# 删除在线文档(异步操作,会自动有限轮询任务状态)
# 删除在线文档
lark-cli drive +delete \
--file-token <DOCX_TOKEN> \
--type docx \
@@ -56,30 +52,22 @@ lark-cli drive +delete \
## 行为说明
- **删除可能需要等待**删除操作在服务端可能异步处理shortcut 会在本次命令内自动做有限次数的结果轮询
- **已完成则停止**:如果返回 `deleted=true`,且没有返回 `next_command`,说明删除已经完成,不需要再调用 `drive +task_result`
- **未完成再续查**:如果超过内置轮询次数仍未完成,会返回 `ready=false``timed_out=true``task_id``next_command`;此时按 `next_command` 继续查询删除结果
- **task_id 不是成功条件**`task_id` 只是续查凭据。没有 `task_id` 但返回 `deleted=true` 时,也表示删除已完成
- **失败处理**:如果返回 `failed=true``status=fail`,按错误信息和 `task_id` 报告删除失败;不要重复删除同一资源
## 常见错误处理
| 错误码 | 含义 | 建议处理 |
|--------|------|----------|
| `1061007` | 文件已删除 | 视为目标已不可用,无需重试删除 |
| `99991400` | 命中接口限频 | 等待一段时间后重试;批量删除时保持串行并降低频率 |
| `99991679` | 缺失 scope | 按错误里的 `missing_scopes``hint` 申请/授权所需 scope 后重试 |
- **普通文件删除**:同步操作,成功时直接返回 `deleted=true`
- **文件夹删除**:异步操作,接口返回 `task_id`shortcut 会先做有限轮询;如果在轮询窗口内完成,则直接返回成功结果
- **轮询超时不是失败**:文件夹删除内置最多轮询 30 次、每次间隔 2 秒;如果轮询结束任务仍未完成,会返回 `task_id``status``ready=false``timed_out=true``next_command`
- **继续查询**:当看到 `next_command` 时,改用 `lark-cli drive +task_result --scenario task_check --task-id <TASK_ID>` 继续查询
- **状态值**`task_check` 的服务端状态通常是 `success``fail``process`
## 推荐续跑方式
```bash
# 第一步:先直接删除资源
# 第一步:先直接删除文件夹
lark-cli drive +delete \
--file-token <FILE_OR_FOLDER_TOKEN> \
--type <TYPE> \
--file-token <FOLDER_TOKEN> \
--type folder \
--yes
# 只有返回 ready=false / timed_out=true 或 next_command 时,才需要继续查
# 如果返回 ready=false / timed_out=true,再继续查
lark-cli drive +task_result \
--scenario task_check \
--task-id <TASK_ID>

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
查询异步任务结果。该 shortcut 聚合了导入、导出、Drive 文件/文件夹移动/删除、Wiki 节点 / 文档迁入 Wiki、Wiki 节点移出 Wiki、Wiki 删除等多种异步任务的结果查询,统一接口方便调用。
查询异步任务结果。该 shortcut 聚合了导入、导出、移动/删除文件夹、Wiki 节点 / 文档迁入 Wiki、Wiki 节点移出 Wiki 等多种异步任务的结果查询,统一接口方便调用。
> [!IMPORTANT]
> 对于 `import` 场景,如果使用 `--as bot` 且这次查询**已经拿到最终在线文档目标**`ready=true` 且返回了最终 `token` / `url`CLI 会**再次尝试为当前 CLI 用户自动授予该资源的 `full_access`(可管理权限)**。
@@ -31,7 +31,7 @@ lark-cli drive +task_result \
--ticket <EXPORT_TICKET> \
--file-token <SOURCE_DOC_TOKEN>
# 查询 Drive 文件/文件夹移动/删除任务状态
# 查询移动/删除文件夹任务状态
lark-cli drive +task_result \
--scenario task_check \
--task-id <TASK_ID>
@@ -56,7 +56,7 @@ lark-cli drive +task_result \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--scenario` | 是 | 任务场景,可选值:`import` (导入任务)`export` (导出任务)`task_check` (Drive 文件/文件夹移动/删除任务)`wiki_move` (Wiki 移动任务)`wiki_move_to_drive` (Wiki 节点移出知识库任务)`wiki_delete_space` (Wiki 删除知识空间任务)`wiki_delete_node` (Wiki 删除节点任务) |
| `--scenario` | 是 | 任务场景,可选值:`import``export``task_check``wiki_move``wiki_move_to_drive``wiki_delete_space``wiki_delete_node` |
| `--ticket` | 条件必填 | 异步任务 ticket**import/export 场景必填** |
| `--task-id` | 条件必填 | 异步任务 ID**task_check 及所有 wiki 场景必填**;必须原样传递完整 ID |
| `--file-token` | 条件必填 | 导出任务对应的源文档 token**export 场景必填** |
@@ -67,7 +67,7 @@ lark-cli drive +task_result \
|------|------|----------|
| `import` | 文档导入任务(如将本地文件导入为云文档) | `--ticket` |
| `export` | 文档导出任务(如云文档导出为 PDF/Word | `--ticket``--file-token` |
| `task_check` | Drive 文件/文件夹移动/删除任务 | `--task-id` |
| `task_check` | 文件夹移动/删除任务 | `--task-id` |
| `wiki_move` | Wiki 移动任务(`wiki +move` 的 docs-to-wiki 异步流程,超时后续跑用) | `--task-id` |
| `wiki_move_to_drive` | Wiki 节点移出知识库任务(`wiki +move-to-drive` 超时后续跑用) | `--task-id` |
| `wiki_delete_space` | Wiki 删除知识空间任务(`wiki +delete-space` 的异步流程,超时后续跑用) | `--task-id` |

View File

@@ -2092,6 +2092,19 @@
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="ChartGradientKindType">
<xs:annotation>
<xs:documentation>
图表渐变类型
可选值: linear(线性渐变) | radial(径向渐变)
</xs:documentation>
</xs:annotation>
<xs:restriction base="xs:string">
<xs:enumeration value="linear"/>
<xs:enumeration value="radial"/>
</xs:restriction>
</xs:simpleType>
<xs:simpleType name="ChartRadarShapeType">
<xs:annotation>
<xs:documentation>
@@ -2294,10 +2307,10 @@
图表背景配置
属性:
- color: 背景颜色, 默认透明 rgba(0,0,0,0)
- color: 背景颜色,省略时使用图表默认背景;无填充可使用透明 rgba(0,0,0,0)
</xs:documentation>
</xs:annotation>
<xs:attribute name="color" type="sml:SolidColor" use="optional" default="rgb(255, 255, 255)"/>
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartBorderType">
@@ -2307,7 +2320,7 @@
属性:
- color: 边框颜色,默认 rgb(222, 224, 227)
- width: 边框宽度(像素), 默认 1
- width: 边框宽度(像素), 默认 1无边框可设置为0或不设置chartBorder
- style: 边框样式(solid|dashed|dotted), 默认 solid
- radius: 圆角半径(像素), 默认 6
</xs:documentation>
@@ -2318,6 +2331,61 @@
<xs:attribute name="radius" type="xs:nonNegativeInteger" use="optional" />
</xs:complexType>
<xs:complexType name="ChartGradientStopType">
<xs:annotation>
<xs:documentation>
图表渐变色标
属性:
- offset: 色标位置比例[0,1]
- color: 色标颜色
- opacity: 色标透明度[0,1]
</xs:documentation>
</xs:annotation>
<xs:attribute name="offset" type="sml:RatioType" use="required"/>
<xs:attribute name="color" type="sml:SolidColor" use="required"/>
<xs:attribute name="opacity" type="sml:RatioType" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartGradientStopsType">
<xs:annotation>
<xs:documentation>
图表渐变色标列表至少需要2个色标
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="stop" type="sml:ChartGradientStopType" minOccurs="2" maxOccurs="unbounded"/>
</xs:sequence>
</xs:complexType>
<xs:complexType name="ChartGradientType">
<xs:annotation>
<xs:documentation>
图表渐变配置
属性:
- type: 渐变类型(linear|radial)
- x0/y0/x1/y1: 线性渐变起止点坐标
- r0/r1: 径向渐变半径
- gradientMethod: 渐变算法/插值方式
子元素:
- stops: 渐变色标列表
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="stops" type="sml:ChartGradientStopsType" minOccurs="1"/>
</xs:sequence>
<xs:attribute name="type" type="sml:ChartGradientKindType" use="required"/>
<xs:attribute name="x0" type="xs:double" use="optional"/>
<xs:attribute name="y0" type="xs:double" use="optional"/>
<xs:attribute name="x1" type="xs:double" use="optional"/>
<xs:attribute name="y1" type="xs:double" use="optional"/>
<xs:attribute name="r0" type="xs:double" use="optional"/>
<xs:attribute name="r1" type="xs:double" use="optional"/>
<xs:attribute name="gradientMethod" type="xs:string" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartColorThemeType">
<xs:annotation>
<xs:documentation>
@@ -2427,12 +2495,16 @@
- size: 该系列所有点的大小
子元素:
- fillGradient: 该系列所有点的填充渐变(可选)
- strokeGradient: 该系列所有点的边框/描边渐变(可选)
- chartPoint: 单个数据点配置(可选, 多个), 用于覆盖特定点的样式
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalPointsType">
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="chartPoint" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:annotation>
@@ -2444,8 +2516,14 @@
- color: 该点的颜色
- shape: 该点的形状(circle|square|triangle|diamond|rect)
- size: 该点的大小(像素)
子元素:
- fillGradient: 该点填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
<xs:attribute name="shape" type="sml:ChartPointShapeType" use="optional"/>
@@ -2458,7 +2536,7 @@
</xs:complexType>
<!-- 线条配置 -->
<xs:complexType name="ChartLineType">
<xs:complexType name="ChartGlobalLineType">
<xs:annotation>
<xs:documentation>
图表全局线条配置(第一层:所有系列的默认样式)
@@ -2475,8 +2553,27 @@
<xs:attribute name="style" type="sml:ChartLineStyleType" use="optional" default="solid"/>
</xs:complexType>
<xs:complexType name="ChartSeriesLineType">
<xs:annotation>
<xs:documentation>
图表系列线条配置(第二层:单系列统一配置)
继承ChartGlobalLineType的所有属性
子元素:
- strokeGradient: 该系列线条渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalLineType">
<xs:sequence>
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!-- 面积配置 -->
<xs:complexType name="ChartAreaType">
<xs:complexType name="ChartGlobalAreaType">
<xs:annotation>
<xs:documentation>
图表全局面积配置(第一层:所有系列的默认填充样式)
@@ -2489,6 +2586,25 @@
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
</xs:complexType>
<xs:complexType name="ChartSeriesAreaType">
<xs:annotation>
<xs:documentation>
图表系列面积配置(第二层:单系列统一配置)
继承ChartGlobalAreaType的所有属性
子元素:
- fillGradient: 该系列面积填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalAreaType">
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
</xs:extension>
</xs:complexContent>
</xs:complexType>
<!-- 柱子配置 -->
<xs:complexType name="ChartGlobalBarsType">
<xs:annotation>
@@ -2529,12 +2645,16 @@
- borderStyle: 该系列所有柱子的边框样式
子元素:
- fillGradient: 该系列所有柱子的填充渐变(可选)
- strokeGradient: 该系列所有柱子的边框渐变(可选)
- chartBar: 单个柱子配置(可选, 多个), 用于覆盖特定柱子的样式
</xs:documentation>
</xs:annotation>
<xs:complexContent>
<xs:extension base="sml:ChartGlobalBarsType">
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="strokeGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="chartBar" minOccurs="0" maxOccurs="unbounded">
<xs:complexType>
<xs:annotation>
@@ -2547,8 +2667,14 @@
- borderColor: 该柱子的边框颜色
- borderWidth: 该柱子的边框宽度(像素)
- borderStyle: 该柱子的边框样式(solid|dashed|dotted)
子元素:
- fillGradient: 该柱子的填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
<xs:attribute name="color" type="sml:SolidColor" use="optional"/>
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
@@ -2573,8 +2699,14 @@
- offsetRadius: 扇区径向偏移比例[0,1], 用于突出显示
- borderColor: 扇区边框颜色
- color: 扇区填充颜色
子元素:
- fillGradient: 扇区填充渐变(可选)
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
</xs:sequence>
<xs:attribute name="index" type="xs:positiveInteger" use="required"/>
<xs:attribute name="offsetRadius" type="sml:RatioType" use="optional"/>
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
@@ -2594,10 +2726,12 @@
- startAngle: 起始角度[0,360), 控制第一个扇区的起始位置, 默认0
子元素:
- fillGradient: 所有扇区的统一填充渐变(可选)
- chartSector: 单个扇区配置(可选, 多个), 用于定制特定扇区
</xs:documentation>
</xs:annotation>
<xs:sequence>
<xs:element name="fillGradient" type="sml:ChartGradientType" minOccurs="0"/>
<xs:element name="chartSector" type="sml:ChartSectorType" minOccurs="0" maxOccurs="unbounded"/>
</xs:sequence>
<xs:attribute name="borderColor" type="sml:SolidColor" use="optional"/>
@@ -2644,8 +2778,8 @@
</xs:annotation>
<xs:sequence>
<xs:element name="chartPoints" type="sml:ChartSeriesPointsType" minOccurs="0"/>
<xs:element name="chartLine" type="sml:ChartLineType" minOccurs="0"/>
<xs:element name="chartArea" type="sml:ChartAreaType" minOccurs="0"/>
<xs:element name="chartLine" type="sml:ChartSeriesLineType" minOccurs="0"/>
<xs:element name="chartArea" type="sml:ChartSeriesAreaType" minOccurs="0"/>
<xs:element name="chartBars" type="sml:ChartSeriesBarsType" minOccurs="0"/>
<xs:element name="chartSectors" type="sml:ChartSectorsType" minOccurs="0"/>
<xs:element name="chartLabels" type="sml:ChartDataLabelsType" minOccurs="0"/>
@@ -2846,8 +2980,8 @@
</xs:annotation>
<xs:all>
<xs:element name="chartPoints" type="sml:ChartGlobalPointsType" minOccurs="0"/>
<xs:element name="chartLines" type="sml:ChartLineType" minOccurs="0"/>
<xs:element name="chartAreas" type="sml:ChartAreaType" minOccurs="0"/>
<xs:element name="chartLines" type="sml:ChartGlobalLineType" minOccurs="0"/>
<xs:element name="chartAreas" type="sml:ChartGlobalAreaType" minOccurs="0"/>
<xs:element name="chartBars" type="sml:ChartGlobalBarsType" minOccurs="0"/>
<xs:element name="chartLabels" type="sml:ChartDataLabelsType" minOccurs="0"/>
<xs:element name="chartSeriesList" type="sml:ChartSeriesListType" minOccurs="0"/>

View File

@@ -314,6 +314,48 @@
</chart>
```
关于 `<chartStyle>` 里两个常用子元素的写法:
- `<chartBackground>``color` 省略时由渲染端决定默认背景(不再默认白色);需要完全透明请显式写 `color="rgba(0, 0, 0, 0)"`
- `<chartBorder>`:无边框可写 `width="0"`,或直接不写 `<chartBorder>` 元素。
#### 图表渐变 `<fillGradient>` / `<strokeGradient>`
图表支持渐变填充/描边,两种槽区分用途:
- `<fillGradient>`:填充渐变,用于面积、柱子、数据点、扇区
- `<strokeGradient>`:描边渐变,用于线条、数据点边框、柱子边框
**只能挂在系列级或单元素级,不要挂在 `<chartPlot>` 全局层。** transform 链路不消费全局层的渐变(`<chartPlot>` 下的 `<chartLines>` / `<chartAreas>` / `<chartBars>` / `<chartPoints>`),写了没有效果。
可挂载位置:
- ✅ 系列级:`<chartSeries>` 下的 `<chartLine>` / `<chartArea>` / `<chartBars>` / `<chartPoints>` / `<chartSectors>`(同时支持 `<fillGradient>``<strokeGradient>`
- ✅ 单元素级:`<chartBar index="…">` / `<chartPoint index="…">` / `<chartSector index="…">`**仅支持 `<fillGradient>`**,不支持 `<strokeGradient>`
- ❌ 全局级:`<chartPlot>` 下的 `<chartLines>` / `<chartAreas>` / `<chartBars>` / `<chartPoints>`
结构要点:
- `type` 必填:`linear`(线性)或 `radial`(径向)
- `linear``x0` / `y0` / `x1` / `y1` 指定起止点坐标
- `radial``r0` / `r1` 指定内外半径(可选 `gradientMethod`
- 子元素 `<stops>` 至少包含 2 个 `<stop>``offset``opacity` 取值均为 [0, 1]
最小示例(在系列级柱子上加线性渐变填充):
```xml
<chartSeries index="1">
<chartBars>
<fillGradient type="linear" x0="0" y0="0" x1="0" y1="1">
<stops>
<stop offset="0" color="rgb(28, 71, 120)"/>
<stop offset="1" color="rgb(28, 71, 120)" opacity="0.3"/>
</stops>
</fillGradient>
</chartBars>
</chartSeries>
```
## 样式元素
### `<fill>`

View File

@@ -2,8 +2,8 @@
## Metrics
- Denominator: 32 leaf commands
- Covered: 13
- Coverage: 40.6%
- Covered: 11
- Coverage: 34.4%
## Summary
- TestDrive_FilesCreateFolderWorkflow: proves `drive files create_folder` in `create_folder as bot`; helper asserts the returned folder token and registers best-effort cleanup via `drive files delete`.
@@ -16,7 +16,6 @@
- TestDriveAddCommentMarkdownFileWorkflow: opt-in live workflow skeleton for comment write/read, gated by `LARK_DRIVE_MD_COMMENT_E2E=1`; creates a Markdown file, adds a file comment, lists it back through `drive +list-comments`, and cleans up.
- TestDrive_SecureLabelDryRun: dry-run coverage for `drive +secure-label-list` and `drive +secure-label-update`; asserts label-list query params and update URL→type inference, request method/URL/type query, and `label-id` body shape. Runs without hitting live APIs because update can trigger document-level security approval flows.
- TestDriveExportDryRun_FileNameMetadata / TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask / TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask / TestDriveExportDryRun_MarkdownFetchAPI / TestDriveExportDryRun_BitableBaseOnlySchema: dry-run coverage for `drive +export`; asserts export task request shape, Wiki URL and `--doc-type wiki` token `get_node -> export_tasks` planning, markdown fetch request shape without docs fetch `extra_param`, local `--file-name` / `--output-dir` metadata, and `bitable` `.base` `only_schema` request body without calling live APIs.
- TestDriveDeleteDryRunAsyncParams / TestDrive_DeleteAsyncWorkflow: dry-run coverage for `drive +delete` pins `DELETE /drive/v1/files/:file_token` params with `type` plus `async=true` and the follow-up `task_check` plan; live workflow creates and deletes a docx, an empty folder, and a non-empty folder, asserts each delete returns `task_id`, queries every returned task via `drive +task_result --scenario task_check`, and verifies the targets disappear.
- TestDrive_PullDryRun / TestDrive_PullDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +pull`; asserts the list-files request shape, Validate-stage safety guards, and acceptance of `--on-duplicate-remote=rename|newest|oldest` by the real CLI binary.
- TestDrive_PushDryRun / TestDrive_PushDryRunAcceptsDuplicateRemoteStrategies: dry-run coverage for `drive +push`; asserts the list-files request shape, Validate-stage safety guards, conditional delete preflight, and acceptance of `--on-duplicate-remote=newest|oldest` by the real CLI binary.
- Cleanup note: `drive files delete` is only exercised in cleanup and is intentionally left uncovered.
@@ -30,7 +29,7 @@
| ✓ | drive +add-comment | shortcut | drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_File; drive_add_comment_dryrun_test.go::TestDriveAddCommentDryRun_Base | `--doc` file URL vs bare token + `--type file`; supported-extension metadata gate; placeholder `anchor.block_id`; Base URL with `--block-id <table-id>!<record-id>!<view-id>` | dry-run coverage in place; opt-in live file workflow exists behind `LARK_DRIVE_MD_COMMENT_E2E=1` |
| ✓ | drive +list-comments | shortcut | drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_DocxDefaults; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_AppsPageURL; drive_list_comments_dryrun_test.go::TestDriveListCommentsDryRun_WikiToken; drive_add_comment_workflow_test.go::TestDriveAddCommentMarkdownFileWorkflow | `--url`; apps `/page/<token>` URL; `--token + --type wiki`; `--solved-status=false\|all`; `--comment-scope=all\|partial`; `--need-relation`; `--page-size` | dry-run locks URL/token parsing, apps `file_type=apps`, default unresolved filter, omitted all-scope filter, omitted `user_id_type`, and Wiki unwrap request shape; opt-in live workflow verifies a created file comment can be listed back |
| ✓ | drive +apply-permission | shortcut | drive_apply_permission_dryrun_test.go::TestDrive_ApplyPermissionDryRun | `--token` URL vs bare; `--type` (enum) with URL inference; `--perm view\|edit`; `--remark` optional | dry-run only; no live-apply E2E because a real request pushes a card to the owner |
| | drive +delete | shortcut | drive_delete_dryrun_test.go::TestDriveDeleteDryRunAsyncParams + drive_delete_workflow_test.go::TestDrive_DeleteAsyncWorkflow | `--file-token`; `--type`; fixed query `async=true`; `task_check` follow-up | dry-run locks async request shape; live workflow covers docx, empty folder, and non-empty folder async deletion |
| | drive +delete | shortcut | | none | no primary delete workflow yet |
| ✕ | drive +download | shortcut | | none | no file fixture workflow yet |
| ✓ | drive +export | shortcut | drive_export_dryrun_test.go::TestDriveExportDryRun_FileNameMetadata + TestDriveExportDryRun_WikiURLPlansResolveBeforeExportTask + TestDriveExportDryRun_WikiTokenTypePlansResolveBeforeExportTask + TestDriveExportDryRun_MarkdownFetchAPI + TestDriveExportDryRun_BitableBaseOnlySchema | `--url`; `--token`; `--doc-type`; `--file-extension`; `--file-name`; `--output-dir`; `--only-schema`; Wiki URL / `--doc-type wiki` resolve step; markdown fetch omits docs fetch `extra_param` | dry-run only; no live export workflow yet |
| ✕ | drive +export-download | shortcut | | none | no export-download workflow yet |
@@ -42,7 +41,7 @@
| ✓ | drive +secure-label-update | shortcut | drive_secure_label_dryrun_test.go::TestDrive_SecureLabelDryRun | `--token` URL inference; `--type`; `--label-id` body | dry-run only; live update can require document-level approval or mutate a fixture document's security level |
| ✓ | drive +status | shortcut | drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_status_dryrun_test.go::TestDrive_StatusDryRun + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--local-dir`; `--folder-token`; bucketed `new_local` / `new_remote` / `modified` / `unchanged` outputs | dry-run pins request shape; live workflows cover both normal hashing buckets and duplicate-remote failure |
| ✓ | drive +sync | shortcut | drive_sync_dryrun_test.go::TestDrive_SyncDryRun + drive_sync_workflow_test.go::TestDrive_SyncWorkflow + drive_sync_workflow_test.go::TestDrive_SyncEmptyDirWorkflow | `--local-dir`; `--folder-token`; `--on-conflict=remote-wins\|local-wins\|keep-both\|ask`; `--on-duplicate-remote=fail\|newest\|oldest`; `--quick` | dry-run validates request shape, flag acceptance, and path safety guards; live workflow proves new_remote→pull, new_local→push, remote-wins/local-wins/keep-both conflict resolution, empty directory creation, and post-sync convergence |
| | drive +task_result | shortcut | drive_delete_workflow_test.go::TestDrive_DeleteAsyncWorkflow | `--scenario task_check`; `--task-id` | live delete workflow verifies task polling command can read returned delete tasks |
| | drive +task_result | shortcut | | none | no async task-result workflow yet |
| ✓ | drive +upload | shortcut | drive_upload_dryrun_test.go::TestDriveUploadDryRun_WikiTarget + drive_upload_dryrun_test.go::TestDriveUploadDryRun_WithFileToken + drive_upload_workflow_test.go::TestDrive_UploadWorkflow + drive_status_workflow_test.go::TestDrive_StatusWorkflow + drive_duplicate_sync_workflow_test.go::TestDrive_DuplicateRemoteWorkflow | `--wiki-token`; `--file-token`; `parent_type=wiki`; `parent_node`; named uploads into Drive folders; in-place overwrite uploads | dry-run covers wiki-target and overwrite request shapes; live workflows assert returned file tokens, token-stable overwrite behavior, and that uploaded fixtures are consumable by downstream commands |
| ✕ | drive file.comment.replys create | api | | none | no reply workflow yet |
| ✕ | drive file.comment.replys delete | api | | none | no reply workflow yet |

View File

@@ -1,59 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
)
func TestDriveDeleteDryRunAsyncParams(t *testing.T) {
setDriveDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+delete",
"--file-token", "docxDryRunDelete",
"--type", "docx",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
out := result.Stdout
if got := clie2e.DryRunGet(out, "api.#").Int(); got != 2 {
t.Fatalf("api count=%d, want 2\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.method").String(); got != "DELETE" {
t.Fatalf("api.0.method=%q, want DELETE\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.url").String(); got != "/open-apis/drive/v1/files/docxDryRunDelete" {
t.Fatalf("api.0.url=%q, want delete files endpoint\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.0.params.type").String(); got != "docx" {
t.Fatalf("api.0.params.type=%q, want docx\nstdout:\n%s", got, out)
}
async := clie2e.DryRunGet(out, "api.0.params.async")
if !async.Exists() || !async.Bool() {
t.Fatalf("api.0.params.async=%v, want true\nstdout:\n%s", async.Value(), out)
}
if got := clie2e.DryRunGet(out, "api.1.method").String(); got != "GET" {
t.Fatalf("api.1.method=%q, want GET\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.url").String(); got != "/open-apis/drive/v1/files/task_check" {
t.Fatalf("api.1.url=%q, want task_check endpoint\nstdout:\n%s", got, out)
}
if got := clie2e.DryRunGet(out, "api.1.params.task_id").String(); got != "<task_id>" {
t.Fatalf("api.1.params.task_id=%q, want placeholder\nstdout:\n%s", got, out)
}
}

View File

@@ -1,94 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestDrive_DeleteAsyncWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
parentFolderToken := createDriveFolder(t, parentT, ctx, "lark-cli-e2e-drive-delete-"+suffix, "")
t.Run("docx", func(t *testing.T) {
docToken := createDeleteWorkflowDoc(t, ctx, parentFolderToken, "lark-cli-e2e-drive-delete-docx-"+suffix)
taskID := deleteAsyncAndVerify(t, ctx, docToken, "docx")
t.Logf("docx delete task_id=%s token=%s", taskID, docToken)
})
t.Run("empty folder", func(t *testing.T) {
folderToken := createDriveFolder(t, parentT, ctx, "empty-"+suffix, parentFolderToken)
taskID := deleteAsyncAndVerify(t, ctx, folderToken, "folder")
t.Logf("empty folder delete task_id=%s token=%s", taskID, folderToken)
})
t.Run("nonempty folder", func(t *testing.T) {
folderToken := createDriveFolder(t, parentT, ctx, "nonempty-"+suffix, parentFolderToken)
_ = createDeleteWorkflowDoc(t, ctx, folderToken, "nested-doc-"+suffix)
taskID := deleteAsyncAndVerify(t, ctx, folderToken, "folder")
t.Logf("nonempty folder delete task_id=%s token=%s", taskID, folderToken)
})
}
func createDeleteWorkflowDoc(t *testing.T, ctx context.Context, folderToken, title string) string {
t.Helper()
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"docs", "+create",
"--parent-token", folderToken,
"--doc-format", "markdown",
"--content", "# " + title + "\n\nCreated by drive delete async workflow.",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
docToken := gjson.Get(result.Stdout, "data.document.document_id").String()
require.NotEmpty(t, docToken, "stdout:\n%s", result.Stdout)
return docToken
}
func deleteAsyncAndVerify(t *testing.T, ctx context.Context, token, docType string) string {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"drive", "+delete", "--file-token", token, "--type", docType, "--yes"},
DefaultAs: "bot",
}, driveDeleteRetry)
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
taskID := gjson.Get(result.Stdout, "data.task_id").String()
require.NotEmpty(t, taskID, "delete must return async task_id\nstdout:\n%s", result.Stdout)
taskResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"drive", "+task_result", "--scenario", "task_check", "--task-id", taskID},
DefaultAs: "bot",
})
require.NoError(t, err)
taskResult.AssertExitCode(t, 0)
taskResult.AssertStdoutStatus(t, true)
require.Equal(t, taskID, gjson.Get(taskResult.Stdout, "data.task_id").String(), "stdout:\n%s", taskResult.Stdout)
require.False(t, gjson.Get(taskResult.Stdout, "data.failed").Bool(), "stdout:\n%s", taskResult.Stdout)
require.NoError(t, waitDriveResourceDeleted(ctx, token, docType, "bot", driveDeleteVisibilityWait))
return taskID
}

View File

@@ -108,7 +108,7 @@ func deleteDriveResourceAndVerify(ctx context.Context, token, docType, defaultAs
}
if err := waitDriveResourceDeleted(ctx, token, docType, defaultAs, visibilityWait); err != nil {
return deleteResult, clie2e.CleanupWarning(
fmt.Errorf("drive resource %s/%s still visible after async delete: %w", docType, token, err),
fmt.Errorf("drive resource %s/%s still visible after accepted delete: %w", docType, token, err),
)
}
return deleteResult, nil