Compare commits

..

9 Commits

Author SHA1 Message Date
zhanghuanxu
dae3e5501d docs(slides): document table dimensions 2026-07-16 21:54:41 +08:00
liangshuo-1
708196040a chore: release v1.0.71 (#1919) 2026-07-16 20:34:43 +08:00
yballul-bytedance
65586577a3 feat(drive): add secure label support and clarify comment location API (#1913)
Co-authored-by: yballul-bytedance <273011618+yballul-bytedance@users.noreply.github.com>
2026-07-16 18:09:38 +08:00
wangweiming-01
be1f3621de perf(drive): optimize drive +delete workflow (#1909) 2026-07-16 16:21:37 +08:00
chenxingyang1019
65998a21e3 docs(apps): add platform SQL authoring guide to the db-execute skill (#1912)
* docs(apps): add platform SQL authoring guide to the db-execute skill

Aligns the lark-cli apps +db-execute skill with the Miaoda platform's
SQL constraints (the same dataloom backend the sandbox miaoda-sql skill
targets), so agents writing SQL via the CLI don't get server-rejected or
build tables that misbehave. Previously the skill covered only the command
contract with zero SQL-content guidance.

Adds a "平台 SQL 规范" section to lark-apps-db-execute.md covering:
- Platform-forbidden SQL (DATABASE/SCHEMA/USER/ROLE/OWNED) that hard-rejects
- CREATE TABLE template: 4 audit columns + RLS + 4 default policies
- user_profile compound type (ROW()::user_profile, (field).user_id, index)
- Audit column names and semantics
- DDL rules: IF NOT EXISTS support matrix; pre-check online rows before
  adding constraints, split by UNIQUE / tighten-to-NOT-NULL / new-NOT-NULL-column
- SELECT / DML safety rules and common PostgreSQL pitfalls

Sandbox-specific bits (miaoda command names, generate_image, test-user
list, schema.ts codegen, string error codes) are intentionally excluded.
Wires pointers from SKILL.md routing and lark-apps-db.md.

* docs(apps): address review nits on the db-execute SQL guide

- Drop the IF NOT EXISTS support matrix (redundant / conflicted with the
  extension-allowlist note in the callout).
- Use `orders` instead of the built-in composite type `user_profile` as the
  bare-table-name example, which was misleading.
- Remove the "user_profile PRIMARY KEY" suggestion for person tables (the
  composite carries mutable fields; a uuid PK is the right default).
2026-07-16 16:05:26 +08:00
zhouyue-bytedance
d5afe3f705 fix(base): improve dashboard shortcut guidance (#1787)
* fix(base): improve dashboard shortcut guidance

* docs(base): refine dashboard funnel guidance

* docs(base): drop redundant block-get audit tip

The 'do not audit every block after creation' hint duplicates the
create-then-suppress-get guidance already in lark-base-dashboard.md,
so remove it from the +dashboard-block-get tips to keep them focused.

* test(base): drop stale block-get audit tip assertion

Commit 778da63a removed the 'do not audit every block' tip from the
+dashboard-block-get source as redundant but left the matching
assertion in TestBaseDashboardHelpGuidesAgents, breaking the unit
test. Remove the stale assertion to realign the test with the tips.

* docs(base): clarify when NOT to use helper table for dashboard blocks

* fix(base): defer record-list --json to framework shorthand (align with main)

* fix(base): reject non-string dashboard sort.order instead of silently defaulting to asc

* docs(base): fix reversed cumulative-funnel direction (suffix sum + assumptions)

* docs(base): scope dashboard-arrange to explicit request or fresh new dashboard

* test(base): pin missing sort.order behavior; clarify --no-validate is raw pass-through

* docs(base): show real CLI envelope {ok,identity,data} for get-data and data-query outputs
2026-07-16 15:10:15 +08:00
linchao5102
baf6050f8e feat(apps): add role management shortcuts (#1881) 2026-07-16 14:09:41 +08:00
zhaojunlin0405
a6bc81596a ci: add L4 plugin-integration and sidecar-integration CI jobs (#1840) 2026-07-16 13:47:11 +08:00
liujinkun2025
7f43b7ed5d feat: add wiki move-to-drive shortcut (#1869)
* feat: add wiki move-to-drive shortcut
2026-07-16 11:00:28 +08:00
77 changed files with 10908 additions and 176 deletions

View File

@@ -47,6 +47,34 @@ jobs:
exit 1
fi
plugin-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
# No fetch_meta: the git-archive clean tree must embed only the
# committed meta_data stub (reproduces the bare-module customer state).
- name: Run plugin-integration L4 tests
run: go test -count=1 -timeout=15m ./tests/plugin_e2e/...
sidecar-integration:
needs: fast-gate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
persist-credentials: false
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: go.mod
- name: Run sidecar tag build + HMAC round-trip
run: make sidecar-test
# ── Layer 2: Quality Gate ──────────────────────────────────────────
unit-test:
needs: fast-gate
@@ -176,7 +204,11 @@ jobs:
run: python3 scripts/fetch_meta.py
- name: Run tests with coverage
run: |
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '^github.com/larksuite/cli/tests/cli_e2e/')
# tests/ holds only L3/L4 suites (cli_e2e, plugin_e2e, sidecar_e2e) that
# have dedicated jobs; exclude the whole subtree so none of them runs a
# second time here — and, crucially, so an observe-only suite's failure
# can never block merges through coverage's spot in the results loop.
packages=$(go list ./... | grep -v '^github.com/larksuite/cli/tests/')
go test -race -coverprofile=coverage.txt -covermode=atomic $packages
- name: Upload coverage to Codecov
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
@@ -416,7 +448,7 @@ jobs:
# ── Results Gate (single required check for branch protection) ─────
results:
if: ${{ always() }}
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header]
needs: [fast-gate, unit-test, lint, script-test, deterministic-gate, coverage, deadcode, e2e-dry-run, e2e-live, security, license-header, plugin-integration, sidecar-integration]
runs-on: ubuntu-latest
steps:
- name: Evaluate results
@@ -436,10 +468,19 @@ jobs:
echo "| L3 | e2e-live | ${{ needs.e2e-live.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | security | ${{ needs.security.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | license-header | ${{ needs.license-header.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | plugin-integration (observe-only) | ${{ needs.plugin-integration.result }} |" >> $GITHUB_STEP_SUMMARY
echo "| L4 | sidecar-integration (observe-only) | ${{ needs.sidecar-integration.result }} |" >> $GITHUB_STEP_SUMMARY
# Any failure or cancellation in any job blocks the merge.
# Legitimately skipped jobs (deadcode on push, e2e-live on fork,
# license-header on push) are OK.
#
# plugin-integration and sidecar-integration are intentionally NOT
# in this loop yet: they run on every PR and their status is shown
# in the table above, but a failure is observe-only (non-blocking)
# during the initial soak. Graduation to required is tracked in
# https://github.com/larksuite/cli/issues/1894 (criteria: 4
# consecutive weeks with zero false positives).
FAILED=0
for result in \
"${{ needs.fast-gate.result }}" \

View File

@@ -2,6 +2,27 @@
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
@@ -1506,6 +1527,7 @@ 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

@@ -23,7 +23,7 @@ PREFIX ?= /usr/local
TEST_GOARCH := $(or $(GOARCH),$(shell go env GOARCH))
RACE_FLAG := $(if $(filter riscv64,$(TEST_GOARCH)),,-race)
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks
.PHONY: all build vet fmt-check script-test test unit-test integration-test examples-build quality-gate install uninstall clean fetch_meta gitleaks sidecar-test
all: test
@@ -64,6 +64,9 @@ examples-build:
go build ./extension/platform/examples/audit-observer
go build ./extension/platform/examples/readonly-policy
# ./tests/... includes tests/plugin_e2e, which builds ~20 customer-fork
# binaries (~1 min warm; a cold module cache also downloads via GOPROXY).
# Deliberate: local `make test` exercises the L4 plugin contract by default.
integration-test: build
go test -v -count=1 ./tests/...
@@ -105,6 +108,14 @@ uninstall:
clean:
rm -f $(BINARY)
# sidecar-test compiles and runs the authsidecar* build-tagged code that the
# default CI matrix never sees (they carry //go:build tags).
sidecar-test:
go build -tags authsidecar -o /dev/null .
go test $(RACE_FLAG) -count=1 -tags authsidecar ./extension/credential/sidecar/ ./extension/transport/sidecar/ ./internal/cmdutil/
go test $(RACE_FLAG) -count=1 -tags authsidecar_demo ./sidecar/server-demo/
go test $(RACE_FLAG) -count=1 -tags authsidecar ./tests/sidecar_e2e/
# Run secret-leak checks locally before pushing.
# Step 1: check-doc-tokens catches realistic-looking example tokens in reference
# docs and asks you to use _EXAMPLE_TOKEN placeholders instead.

View File

@@ -0,0 +1,28 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// sparkCodeMeta holds stable Spark app-role business-code classifications.
// Command-specific recovery guidance belongs in the Apps shortcut layer; the
// numeric code remains the source-specific discriminator on the error envelope.
var sparkCodeMeta = map[int]CodeMeta{
3340001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // request parameters are invalid
3344027: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role user count exceeds the service limit
3344028: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role department count exceeds the service limit
3344029: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // role chat count exceeds the service limit
3344030: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // app administrator required
3344031: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // app administrator or developer required
3344034: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role ID
3344035: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // role does not exist
3344036: {Category: errs.CategoryAPI, Subtype: errs.SubtypeAlreadyExists}, // role ID already exists
3344037: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // app role count exceeds the service limit
3344038: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role name
3344039: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid role description
3344040: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // unsupported member type
3344041: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid member ID
}
func init() { mergeCodeMeta(sparkCodeMeta, "spark") }

View File

@@ -0,0 +1,59 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import (
"fmt"
"testing"
"github.com/larksuite/cli/errs"
)
func TestLookupCodeMetaSparkRoleCodes(t *testing.T) {
tests := []struct {
code int
category errs.Category
subtype errs.Subtype
}{
{3340001, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344027, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344028, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344029, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344030, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
{3344031, errs.CategoryAuthorization, errs.SubtypePermissionDenied},
{3344034, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344035, errs.CategoryAPI, errs.SubtypeNotFound},
{3344036, errs.CategoryAPI, errs.SubtypeAlreadyExists},
{3344037, errs.CategoryAPI, errs.SubtypeQuotaExceeded},
{3344038, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344039, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344040, errs.CategoryAPI, errs.SubtypeInvalidParameters},
{3344041, errs.CategoryAPI, errs.SubtypeInvalidParameters},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%d", tt.code), func(t *testing.T) {
meta, ok := LookupCodeMeta(tt.code)
if !ok {
t.Fatalf("code %d is not registered", tt.code)
}
if meta.Category != tt.category || meta.Subtype != tt.subtype || meta.Retryable {
t.Fatalf("code %d metadata = %+v, want category=%s subtype=%s retryable=false", tt.code, meta, tt.category, tt.subtype)
}
err := BuildAPIError(map[string]any{
"code": tt.code,
"msg": "spark role error",
"log_id": "log-spark-role",
}, ClassifyContext{Identity: "user"})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("BuildAPIError(%d) = %#v, want typed problem", tt.code, err)
}
if problem.Category != tt.category || problem.Subtype != tt.subtype || problem.Code != tt.code || problem.LogID != "log-spark-role" || problem.Retryable {
t.Fatalf("BuildAPIError(%d) problem = %+v", tt.code, problem)
}
})
}
}

View File

@@ -337,7 +337,7 @@ func fakeValueFromPlaceholderName(name string) (string, bool) {
case name == "open_id" || hasPlaceholderToken(tokens, "user", "owner", "participant", "approver", "speaker"):
return "ou_test123", true
case hasPlaceholderToken(tokens, "department", "dept"):
return "od_test123", true
return "od-test123", true
case hasPlaceholderToken(tokens, "message"):
return "om_test123", true
case name == "file_key":

View File

@@ -316,6 +316,13 @@ func TestRunDryRunsMaterializesInlinePlaceholderFlagValues(t *testing.T) {
}
}
func TestFakeValueFromPlaceholderNameUsesOpenDepartmentPrefix(t *testing.T) {
got, ok := fakeValueFromPlaceholderName("open_department_id")
if !ok || got != "od-test123" {
t.Fatalf("open_department_id placeholder = %q, %v; want od-test123, true", got, ok)
}
}
func TestRunDryRunsMaterializesNumericPlaceholderFlagValues(t *testing.T) {
cliBin, argsPath := fakeDryRunCLI(t, `{"api":[{"method":"GET","url":"/open-apis/vc/v1/bots/events","params":{"meeting_id":"400000000001","page_size":50}}]}`)
m := manifest.Manifest{Commands: []manifest.Command{{

View File

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

View File

@@ -20,7 +20,7 @@ func TestAppsAccessScopeGet_Specific(t *testing.T) {
"data": map[string]interface{}{
"scope": "Range",
"users": []interface{}{"ou_x", "ou_y"},
"departments": []interface{}{"od_z"},
"departments": []interface{}{"od-z"},
"chats": []interface{}{"oc_g"},
"apply_config": map[string]interface{}{
"enabled": true,
@@ -39,7 +39,7 @@ func TestAppsAccessScopeGet_Specific(t *testing.T) {
if !strings.Contains(got, `"scope": "Range"`) {
t.Fatalf("scope string not preserved (expect raw \"Range\"): %s", got)
}
if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od_z"`) || !strings.Contains(got, `"oc_g"`) {
if !strings.Contains(got, `"ou_x"`) || !strings.Contains(got, `"od-z"`) || !strings.Contains(got, `"oc_g"`) {
t.Fatalf("users/departments/chats fields missing in envelope: %s", got)
}
if !strings.Contains(got, `"ou_appr"`) {

744
shortcuts/apps/apps_role.go Normal file
View File

@@ -0,0 +1,744 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"strconv"
"strings"
"text/tabwriter"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const maxRoleListScanPages = 1000
// AppsRoleList lists app roles.
var AppsRoleList = common.Shortcut{
Service: appsService,
Command: "+role-list",
Description: "List app roles",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +role-list --app-id <app_id>",
"Example: lark-cli apps +role-list --app-id <app_id> --name Admin --page-size 20",
"When only a role name is known, pass --name for exact matching; call +role-get only after resolving one unique role_id",
"With --name, the CLI scans server pages in batches of 100, then applies --page-size and --page-token to the exact local matches",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "name", Desc: "filter roles by exact name"},
{Name: "page-size", Type: "int", Default: "20", Desc: "page size (1-100)"},
{Name: "page-token", Desc: "integer offset returned by the previous role-list response"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleAppID(rctx); err != nil {
return err
}
_, err := buildRoleListParams(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleListParams; error is impossible here.
params, _ := buildRoleListParams(rctx)
params = roleListRequestParams(params, 0)
return common.NewDryRunAPI().
GET(roleListURL(rctx)).
Desc("List app roles").
Params(params)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
params, err := buildRoleListParams(rctx)
if err != nil {
return err
}
data, err := executeRoleList(rctx, params)
if err != nil {
return withRoleErrorHint(err, roleOperationList)
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleListPretty(w, common.GetSlice(data, "items"))
})
return nil
},
}
// AppsRoleGet gets one app role.
var AppsRoleGet = common.Shortcut{
Service: appsService,
Command: "+role-get",
Description: "Get an app role",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +role-get --app-id <app_id> --role-id <role_id>",
"--role-id is not a human-readable role name; if only a name is known, run +role-list --name <exact_name> and use its unique returned role_id before calling +role-get",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
return validateRoleID(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
GET(roleItemURL(rctx)).
Desc("Get app role")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("GET", roleItemURL(rctx), nil, nil)
if err != nil {
return withRoleErrorHint(err, roleOperationGet)
}
role, err := parseRoleDetailResponseData(data, roleID(rctx))
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleGetPretty(w, role)
})
return nil
},
}
// AppsRoleCreate creates an app role.
var AppsRoleCreate = common.Shortcut{
Service: appsService,
Command: "+role-create",
Description: "Create an app role",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin",
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin --description 'Can manage orders'",
"Example: lark-cli apps +role-create --app-id <app_id> --name Admin --role-id role_admin",
"The create response returns data.role; run +role-get with data.role.role_id only when independent verification is required",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
// Keep --name in Validate so the CLI can return the command-specific
// non-invention hint instead of Cobra's generic required-flag error.
{Name: "name", Desc: "role name (required)"},
{Name: "description", Desc: "role description"},
{Name: "role-id", Desc: "optional caller-provided role ID ([A-Za-z0-9_-]{1,64})"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleAppID(rctx); err != nil {
return err
}
if strings.TrimSpace(rctx.Str("name")) == "" {
return appsValidationParamError("--name", "--name is required").
WithHint("ask for the intended role name and pass it with --name; do not infer a name from --description")
}
if rctx.Changed("role-id") {
roleID := strings.TrimSpace(rctx.Str("role-id"))
if roleID == "" {
return appsValidationParamError("--role-id", "--role-id must not be empty when provided")
}
return validateOptionalRoleID(roleID)
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST(roleListURL(rctx)).
Desc("Create app role").
Body(buildRoleCreateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("POST", roleListURL(rctx), nil, buildRoleCreateBody(rctx))
if err != nil {
return withRoleErrorHint(err, roleOperationCreate)
}
expectedRoleID := ""
if rctx.Changed("role-id") {
expectedRoleID = roleID(rctx)
}
role, err := parseRoleWriteResponseData(data, expectedRoleID)
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleCreatePretty(w, role)
})
return nil
},
}
// AppsRoleUpdate updates an app role.
var AppsRoleUpdate = common.Shortcut{
Service: appsService,
Command: "+role-update",
Description: "Update an app role",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +role-update --app-id <app_id> --role-id <role_id> --name Operator",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
{Name: "name", Desc: "new role name"},
{Name: "description", Desc: "new role description"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleID(rctx); err != nil {
return err
}
if rctx.Changed("name") && strings.TrimSpace(rctx.Str("name")) == "" {
return appsValidationParamError("--name", "--name must not be empty when provided").
WithHint("omit --name if only updating --description")
}
if !rctx.Changed("name") && !rctx.Changed("description") {
reason := "provide at least one of --name or --description"
return appsValidationError("at least one of --name or --description is required").
WithParams(
appsInvalidParam("--name", reason),
appsInvalidParam("--description", reason),
).
WithHint("provide --name, --description, or both")
}
return nil
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
PATCH(roleItemURL(rctx)).
Desc("Update app role").
Body(buildRoleUpdateBody(rctx))
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("PATCH", roleItemURL(rctx), nil, buildRoleUpdateBody(rctx))
if err != nil {
return withRoleErrorHint(err, roleOperationUpdate)
}
role, err := parseRoleWriteResponseData(data, roleID(rctx))
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleUpdatePretty(w, role)
})
return nil
},
}
// AppsRoleDelete deletes an app role.
var AppsRoleDelete = common.Shortcut{
Service: appsService,
Command: "+role-delete",
Description: "Delete an app role",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +role-delete --app-id <app_id> --role-id <role_id> --yes",
"A delete request alone is not explicit confirmation: first show the exact app, role, current member scope, and irreversible impact; use --yes only after the user confirms that impact",
"When independent verification is required, use +role-list --name <exact_name> and confirm the deleted role_id is absent; a failed +role-get alone does not prove deletion",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
return validateRoleID(rctx)
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
DELETE(roleItemURL(rctx)).
Desc("Delete app role")
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
data, err := rctx.CallAPITyped("DELETE", roleItemURL(rctx), nil, nil)
if err != nil {
return withRoleErrorHint(err, roleOperationDelete)
}
deletedRoleID := roleID(rctx)
out, err := normalizeRoleDeleteData(data, deletedRoleID)
if err != nil {
return err
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderRoleDeletePretty(w, common.GetString(out, "role_id"))
})
return nil
},
}
func roleListURL(rctx *common.RuntimeContext) string {
appID := roleAppID(rctx)
return fmt.Sprintf(roleListPath, validate.EncodePathSegment(appID))
}
func roleItemURL(rctx *common.RuntimeContext) string {
appID := roleAppID(rctx)
roleID := roleID(rctx)
return fmt.Sprintf(roleItemPath, validate.EncodePathSegment(appID), validate.EncodePathSegment(roleID))
}
func buildRoleListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
params, err := buildRolePageParams(rctx)
if err != nil {
return nil, err
}
name := strings.TrimSpace(rctx.Str("name"))
if rctx.Changed("name") && name == "" {
return nil, appsValidationParamError("--name", "--name must not be empty when provided").
WithHint("omit --name to list all roles, or provide the exact role name to resolve")
}
if name != "" {
params["name"] = name
}
return params, nil
}
// roleListRequestParams returns the query parameters for one actual backend
// request. Exact-name lookup always starts from server offset zero and scans in
// maximum-sized batches; the caller's limit/offset are applied to local matches.
func roleListRequestParams(params map[string]interface{}, page int) map[string]interface{} {
name, _ := params["name"].(string)
if name == "" {
return params
}
return map[string]interface{}{
"limit": maxRolePageSize,
"offset": page * maxRolePageSize,
"name": name,
}
}
func buildRoleCreateBody(rctx *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{
"name": strings.TrimSpace(rctx.Str("name")),
}
if rctx.Changed("description") {
body["description"] = strings.TrimSpace(rctx.Str("description"))
}
if rctx.Changed("role-id") {
if roleID := strings.TrimSpace(rctx.Str("role-id")); roleID != "" {
body["role_id"] = roleID
}
}
return body
}
func buildRoleUpdateBody(rctx *common.RuntimeContext) map[string]interface{} {
body := map[string]interface{}{}
if rctx.Changed("name") {
body["name"] = strings.TrimSpace(rctx.Str("name"))
}
if rctx.Changed("description") {
body["description"] = strings.TrimSpace(rctx.Str("description"))
}
return body
}
// executeRoleList compensates for Miaoda environments that accept the name
// query parameter but ignore it. A name lookup scans the complete server-side
// result set, applies exact matching locally, and then applies the CLI's
// offset/limit contract to the filtered result.
func executeRoleList(rctx *common.RuntimeContext, params map[string]interface{}) (map[string]interface{}, error) {
name, _ := params["name"].(string)
if name == "" {
data, err := rctx.CallAPITyped("GET", roleListURL(rctx), params, nil)
if err != nil {
return nil, err
}
return normalizeRoleListData(data, params)
}
requestedLimit := roleIntValue(params["limit"])
requestedOffset := roleIntValue(params["offset"])
allMatches := make([]interface{}, 0, requestedLimit)
var firstPage map[string]interface{}
seenRoleIDs := map[string]struct{}{}
seenPageSignatures := map[string]struct{}{}
expectedTotal := -1
scannedRoleCount := 0
for page := 0; ; page++ {
if page >= maxRoleListScanPages {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role list exceeded %d pages while filtering by name",
maxRoleListScanPages,
).WithHint("retry without --name and paginate using the returned page_token")
}
scanParams := roleListRequestParams(params, page)
data, err := rctx.CallAPITyped("GET", roleListURL(rctx), scanParams, nil)
if err != nil {
return nil, err
}
if firstPage == nil {
firstPage = data
}
items, hasMore, total, err := parseRoleListPage(data)
if err != nil {
return nil, err
}
if expectedTotal < 0 {
expectedTotal = total
} else if total != expectedTotal {
return nil, roleListProgressError("role list total changed across pages while filtering by name")
}
if scannedRoleCount+len(items) > expectedTotal {
return nil, roleListProgressError("role list returned more roles than its total while filtering by name")
}
scannedRoleCount += len(items)
if hasMore && scannedRoleCount >= expectedTotal {
return nil, roleListProgressError("role list reported more pages after reaching its total while filtering by name")
}
if !hasMore && scannedRoleCount != expectedTotal {
return nil, roleListProgressError("role list ended before returning its declared total while filtering by name")
}
signature, newRoleCount, err := roleListPageProgress(items, seenRoleIDs)
if err != nil {
return nil, err
}
if newRoleCount != len(items) {
return nil, roleListProgressError("role list repeated roles across pages while filtering by name")
}
if _, duplicate := seenPageSignatures[signature]; duplicate {
return nil, roleListProgressError("role list repeated a page while filtering by name")
}
seenPageSignatures[signature] = struct{}{}
if hasMore && (len(items) == 0 || newRoleCount == 0) {
return nil, roleListProgressError("role list reported more pages without returning new roles")
}
for _, item := range items {
role, ok := item.(map[string]interface{})
if ok && common.GetString(role, "name") == name {
allMatches = append(allMatches, item)
}
}
if !hasMore {
break
}
}
if firstPage == nil {
firstPage = map[string]interface{}{}
}
return normalizeFilteredRoleListData(firstPage, allMatches, requestedOffset, requestedLimit), nil
}
func normalizeFilteredRoleListData(data map[string]interface{}, matches []interface{}, offset, limit int) map[string]interface{} {
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
start := offset
if start > len(matches) {
start = len(matches)
}
end := start + limit
if end > len(matches) {
end = len(matches)
}
hasMore := end < len(matches)
items := append([]interface{}(nil), matches[start:end]...)
if items == nil {
items = []interface{}{}
}
out["items"] = items
out["has_more"] = hasMore
out["page_token"] = roleNextPageToken(start, limit, hasMore)
out["total"] = len(matches)
return out
}
func normalizeRoleListData(data map[string]interface{}, params map[string]interface{}) (map[string]interface{}, error) {
items, hasMore, total, err := parseRoleListPage(data)
if err != nil {
return nil, err
}
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
limit := roleIntValue(params["limit"])
offset := roleIntValue(params["offset"])
out["items"] = items
out["has_more"] = hasMore
out["page_token"] = roleNextPageToken(offset, limit, hasMore)
out["total"] = total
return out, nil
}
func parseRoleListPage(data map[string]interface{}) ([]interface{}, bool, int, error) {
rawItems, hasItems := data["items"]
items, ok := rawItems.([]interface{})
if !hasItems || !ok {
return nil, false, 0, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role list response field items must be an array",
).WithHint("retry the read; do not treat a missing or malformed role list as empty")
}
if err := validateRoleCollection(items, "role list response field items"); err != nil {
return nil, false, 0, err
}
rawHasMore, hasHasMore := data["has_more"]
hasMore, ok := rawHasMore.(bool)
if !hasHasMore || !ok {
return nil, false, 0, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role list response field has_more must be a boolean",
).WithHint("retry the read; pagination is incomplete without a valid has_more value")
}
total, ok := nonNegativeRoleInteger(data["total"])
if _, exists := data["total"]; !exists || !ok {
return nil, false, 0, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role list response field total must be a non-negative integer",
).WithHint("retry the read; do not infer a role count from a missing or malformed total value")
}
return items, hasMore, total, nil
}
func roleListPageProgress(items []interface{}, seenRoleIDs map[string]struct{}) (string, int, error) {
roleIDs := make([]string, 0, len(items))
newRoleCount := 0
for index, item := range items {
_, roleID, err := roleCollectionItem(item, "role list response field items", index)
if err != nil {
return "", 0, err
}
roleIDs = append(roleIDs, roleID)
if _, seen := seenRoleIDs[roleID]; !seen {
seenRoleIDs[roleID] = struct{}{}
newRoleCount++
}
}
return strings.Join(roleIDs, "\x00"), newRoleCount, nil
}
func nonNegativeRoleInteger(value interface{}) (int, bool) {
maxInt := uint64(^uint(0) >> 1)
toInt := func(value int64) (int, bool) {
if value < 0 || uint64(value) > maxInt {
return 0, false
}
return int(value), true
}
switch value := value.(type) {
case int:
if value < 0 {
return 0, false
}
return value, true
case int64:
return toInt(value)
case float64:
maxIntExclusive := math.Ldexp(1, strconv.IntSize-1)
if math.IsNaN(value) || math.IsInf(value, 0) || value < 0 || math.Trunc(value) != value || value >= maxIntExclusive {
return 0, false
}
return int(value), true
case json.Number:
parsed, err := value.Int64()
if err != nil {
return 0, false
}
return toInt(parsed)
case string:
if value == "" || strings.IndexFunc(value, func(r rune) bool {
return r < '0' || r > '9'
}) >= 0 {
return 0, false
}
parsed, err := strconv.ParseUint(value, 10, strconv.IntSize)
if err != nil || parsed > maxInt {
return 0, false
}
return int(parsed), true
default:
return 0, false
}
}
func roleListProgressError(message string) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse, message).
WithHint("retry without --name and paginate manually; do not continue an incomplete exact-name scan")
}
func normalizeRoleDeleteData(data map[string]interface{}, requestedRoleID string) (map[string]interface{}, error) {
if data == nil {
return nil, invalidRoleDeleteResponse("role delete response data must be an object")
}
if len(data) == 0 {
return map[string]interface{}{
"role_id": requestedRoleID,
"deleted": true,
}, nil
}
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
rawRoleID, ok := out["role_id"]
if !ok {
return nil, invalidRoleDeleteResponse("role delete response is missing role_id")
}
actualRoleID, stringOK := rawRoleID.(string)
if !stringOK || actualRoleID != requestedRoleID {
return nil, invalidRoleDeleteResponse(
"role delete response role_id does not match requested role_id %q",
requestedRoleID,
)
}
rawDeleted, ok := out["deleted"]
if !ok {
return nil, invalidRoleDeleteResponse("role delete response is missing deleted")
}
deleted, boolOK := rawDeleted.(bool)
if !boolOK || !deleted {
return nil, invalidRoleDeleteResponse("role delete response did not acknowledge deletion")
}
return out, nil
}
type roleResponseData struct {
RoleID string
Name string
Description string
}
func parseRoleDetailResponseData(data map[string]interface{}, expectedRoleID string) (roleResponseData, error) {
return parseRoleResponseData(data, expectedRoleID, true)
}
func parseRoleWriteResponseData(data map[string]interface{}, expectedRoleID string) (roleResponseData, error) {
return parseRoleResponseData(data, expectedRoleID, false)
}
func parseRoleResponseData(data map[string]interface{}, expectedRoleID string, requireName bool) (roleResponseData, error) {
if data == nil {
return roleResponseData{}, invalidRoleResponse("role response data must be an object")
}
rawRole, exists := data["role"]
role, ok := rawRole.(map[string]interface{})
if !exists || !ok || role == nil {
return roleResponseData{}, invalidRoleResponse("role response field role must be an object")
}
rawRoleID, exists := role["role_id"]
roleID, ok := rawRoleID.(string)
roleID = strings.TrimSpace(roleID)
if !exists || !ok || roleID == "" {
return roleResponseData{}, invalidRoleResponse("role response field role.role_id must be a non-empty string")
}
if expectedRoleID != "" && roleID != expectedRoleID {
return roleResponseData{}, invalidRoleResponse(
"role response role_id %q does not match requested role_id %q",
roleID,
expectedRoleID,
)
}
rawName, nameExists := role["name"]
name, nameOK := rawName.(string)
name = strings.TrimSpace(name)
if requireName && !nameExists {
return roleResponseData{}, invalidRoleResponse("role response field role.name must be a non-empty string")
}
if nameExists && (!nameOK || name == "") {
return roleResponseData{}, invalidRoleResponse("role response field role.name must be a non-empty string")
}
rawDescription, descriptionExists := role["description"]
description, descriptionOK := rawDescription.(string)
if descriptionExists && !descriptionOK {
return roleResponseData{}, invalidRoleResponse("role response field role.description must be a string")
}
return roleResponseData{RoleID: roleID, Name: name, Description: description}, nil
}
func invalidRoleResponse(message string, args ...interface{}) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse, message, args...).
WithHint("retry the role read; do not treat a missing or malformed role as a successful result")
}
func invalidRoleDeleteResponse(message string, args ...interface{}) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse, message, args...).
WithHint("do not claim deletion; verify the target role with +role-list --name <exact_name>")
}
func roleIntValue(value interface{}) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case json.Number:
i, err := strconv.Atoi(v.String())
if err == nil {
return i
}
case string:
i, err := strconv.Atoi(strings.TrimSpace(v))
if err == nil {
return i
}
}
return 0
}
func renderRoleCreatePretty(w io.Writer, role roleResponseData) {
fmt.Fprintf(w, "Created role %s\n", roleDisplayValue(role.RoleID))
}
func renderRoleGetPretty(w io.Writer, role roleResponseData) {
renderRoleDetailPretty(w, role)
}
func renderRoleUpdatePretty(w io.Writer, role roleResponseData) {
fmt.Fprintf(w, "Updated role %s\n", roleDisplayValue(role.RoleID))
}
func renderRoleDeletePretty(w io.Writer, roleID string) {
fmt.Fprintf(w, "Deleted role %s\n", roleDisplayValue(roleID))
}
func renderRoleDetailPretty(w io.Writer, role roleResponseData) {
fmt.Fprintf(w, "role_id: %s\n", roleDisplayValue(role.RoleID))
fmt.Fprintf(w, "name: %s\n", roleDisplayValue(role.Name))
fmt.Fprintf(w, "description: %s\n", roleDisplayValue(role.Description))
}
func renderRoleListPretty(w io.Writer, items []interface{}) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ROLE ID\tNAME\tDESCRIPTION")
for _, item := range items {
role, ok := item.(map[string]interface{})
if !ok {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\n",
roleDisplayValue(firstNonEmpty(common.GetString(role, "role_id"), common.GetString(role, "id"))),
roleDisplayValue(common.GetString(role, "name")),
roleDisplayValue(common.GetString(role, "description")))
}
_ = tw.Flush()
}

View File

@@ -0,0 +1,490 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"fmt"
"regexp"
"strconv"
"strings"
"unicode"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
roleListPath = apiBasePath + "/apps/%s/roles"
roleItemPath = apiBasePath + "/apps/%s/roles/%s"
roleMemberListPath = apiBasePath + "/apps/%s/roles/%s/member_list"
roleMemberAddPath = apiBasePath + "/apps/%s/roles/%s/member_add"
roleMemberRemovePath = apiBasePath + "/apps/%s/roles/%s/member_remove"
roleMatchListPath = apiBasePath + "/apps/%s/user_role_list"
defaultRolePageSize = 20
maxRolePageSize = 100
maxRoleMembers = 100
roleErrInvalidParameters = 3340001
roleErrUserLimitExceeded = 3344027
roleErrDepartmentLimitExceeded = 3344028
roleErrChatLimitExceeded = 3344029
roleErrAdminRequired = 3344030
roleErrManagerRequired = 3344031
roleErrInvalidRoleID = 3344034
roleErrRoleNotFound = 3344035
roleErrRoleAlreadyExists = 3344036
roleErrRoleLimitExceeded = 3344037
roleErrInvalidRoleName = 3344038
roleErrInvalidRoleDescription = 3344039
roleErrUnsupportedMemberType = 3344040
roleErrInvalidMemberID = 3344041
)
var optionalRoleIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
const (
roleAppHint = "verify --app-id is a Miaoda app_id you can access; list apps with `lark-cli apps +list`"
roleItemHint = "verify --role-id belongs to the app; if you only know a role name, resolve it with `lark-cli apps +role-list --app-id <app_id> --name <exact_name>` and use the unique returned role_id"
roleCreateHint = "verify --app-id and role fields; omit --role-id unless you need a caller-provided role ID"
roleMemberHint = "verify --role-id and member IDs; use user open_id, open_department_id, or open_chat_id values"
roleMatchHint = "use --user-id with a user open_id; do not pass role_id or enumerate roles manually"
roleAppIDRequiredDesc = "Miaoda app ID (required; app_...; use apps +list to find it)"
roleIDRequiredDesc = "role ID (required; [A-Za-z0-9_-]{1,64}; use role-list to find it)"
roleUserIDRequiredDesc = "user open ID (required; ou_...; do not pass a role ID, name, or email)"
)
type roleErrorOperation uint8
const (
roleOperationList roleErrorOperation = iota
roleOperationGet
roleOperationCreate
roleOperationUpdate
roleOperationDelete
roleOperationMemberList
roleOperationMemberAdd
roleOperationMemberRemove
roleOperationMatchList
)
type roleMemberGroups struct {
Users []string `json:"users"`
Departments []string `json:"departments"`
Chats []string `json:"chats"`
}
type roleMemberKind struct {
memberType string
dataKey string
flagName string
prefix string
}
var roleMemberKinds = []roleMemberKind{
{memberType: "user", dataKey: "users", flagName: "--users", prefix: "ou_"},
{memberType: "department", dataKey: "departments", flagName: "--departments", prefix: "od-"},
{memberType: "chat", dataKey: "chats", flagName: "--chats", prefix: "oc_"},
}
func roleAppID(rctx *common.RuntimeContext) string {
return strings.TrimSpace(rctx.Str("app-id"))
}
func roleID(rctx *common.RuntimeContext) string {
return strings.TrimSpace(rctx.Str("role-id"))
}
func validateRoleAppID(rctx *common.RuntimeContext) error {
appID := roleAppID(rctx)
if appID == "" {
return appsValidationParamError("--app-id", "--app-id is required").
WithHint("list your apps with `lark-cli apps +list`")
}
if strings.HasPrefix(appID, "cli_") {
return appsValidationParamError("--app-id", "--app-id must be a Miaoda app_id, not a Lark app_id").
WithHint("pass the app_... value from `lark-cli apps +list`, not the cli_... credential app id")
}
if !strings.HasPrefix(appID, "app_") || len(appID) == len("app_") {
return appsValidationParamError("--app-id", "--app-id must be a Miaoda app_id starting with app_").
WithHint("list Miaoda apps with `lark-cli apps +list`, then pass the returned app_id")
}
// app-id must not contain forward slashes (apps are identified by app_xxx IDs).
for _, r := range appID {
if r == '/' || r == '\\' || unicode.IsSpace(r) || unicode.IsControl(r) {
return appsValidationParamError("--app-id", "--app-id must not contain slashes, whitespace, or control characters")
}
}
// Defense-in-depth: block path traversal and URL metacharacters.
if err := validateRolePathSegmentSafe(appID, "--app-id"); err != nil {
return err
}
return nil
}
func validateRoleID(rctx *common.RuntimeContext) error {
if err := validateRoleAppID(rctx); err != nil {
return err
}
roleID := roleID(rctx)
if roleID == "" {
return appsValidationParamError("--role-id", "--role-id is required").
WithHint("list roles with `lark-cli apps +role-list --app-id <app_id>`")
}
return validateExistingRoleIDValue(roleID)
}
// validateRolePathSegmentSafe rejects path-traversal segments ("..") and URL
// metacharacters (? # %) in values interpolated into a URL path, providing
// defense-in-depth alongside validate.EncodePathSegment.
func validateRolePathSegmentSafe(value, flagName string) error {
for _, seg := range strings.Split(value, "/") {
if seg == ".." {
return appsValidationParamError(flagName, "%s must not contain '..' path traversal", flagName).
WithHint("provide a valid %s without path traversal", flagName)
}
}
if strings.ContainsAny(value, "?#%") {
return appsValidationParamError(flagName, "%s contains invalid URL characters (?, #, %%)", flagName).
WithHint("provide a valid %s without URL metacharacters", flagName)
}
return nil
}
func validateOptionalRoleID(roleID string) error {
roleID = strings.TrimSpace(roleID)
if roleID == "" {
return nil
}
return validateCreateRoleIDValue(roleID)
}
func validateCreateRoleIDValue(roleID string) error {
if !optionalRoleIDPattern.MatchString(roleID) {
return appsValidationParamError("--role-id", "--role-id must match [A-Za-z0-9_-]{1,64}").
WithHint("omit --role-id to let the server generate one")
}
return nil
}
func validateExistingRoleIDValue(roleID string) error {
if !optionalRoleIDPattern.MatchString(roleID) {
return appsValidationParamError("--role-id", "--role-id must match [A-Za-z0-9_-]{1,64}").
WithHint("resolve the role with `lark-cli apps +role-list --app-id <app_id> --name <exact_name>` and pass its role_id")
}
return nil
}
func buildRolePageParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
limit := defaultRolePageSize
if rctx.Changed("page-size") {
limit = rctx.Int("page-size")
}
if limit < 1 || limit > maxRolePageSize {
return nil, appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxRolePageSize).
WithHint("use --page-size between 1 and 100")
}
offset := 0
pageToken := strings.TrimSpace(rctx.Str("page-token"))
if pageToken != "" {
parsedOffset, err := strconv.Atoi(pageToken)
if err != nil || parsedOffset < 0 {
return nil, appsValidationParamError("--page-token", "--page-token must be a non-negative integer offset").
WithHint("reuse page_token from the previous +role-list response")
}
offset = parsedOffset
}
return map[string]interface{}{
"limit": limit,
"offset": offset,
}, nil
}
func roleNextPageToken(offset, limit int, hasMore bool) string {
if !hasMore {
return ""
}
return strconv.Itoa(offset + limit)
}
func splitRoleMemberCSV(s, flagName string) ([]string, error) {
parts := strings.Split(s, ",")
values := make([]string, 0, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
continue
}
// Reject values containing whitespace, control characters, or URL metacharacters
// (member IDs are open_id/open_department_id/open_chat_id which are safe tokens).
if err := validateMemberID(value, flagName); err != nil {
return nil, err
}
values = append(values, value)
}
return values, nil
}
// validateMemberID rejects values containing characters that are invalid in
// open_id / open_department_id / open_chat_id tokens (whitespace, controls, URL metacharacters).
func validateMemberID(value, flagName string) error {
if err := validateMemberIDPrefix(value, flagName); err != nil {
return err
}
for _, r := range value {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return appsValidationParamError(flagName, "member IDs must not contain whitespace or control characters").
WithHint("pass comma-separated open_id/open_department_id/open_chat_id values without spaces")
}
if r == '?' || r == '#' || r == '%' || r == '/' || r == '\\' {
return appsValidationParamError(flagName, "member IDs must not contain URL metacharacters (?, #, %, /, \\)").
WithHint("pass comma-separated open_id/open_department_id/open_chat_id values without URL characters")
}
}
return nil
}
func validateMemberIDPrefix(value, flagName string) error {
kind, ok := roleMemberKindForFlag(flagName)
if !ok {
return nil
}
if !strings.HasPrefix(value, kind.prefix) || len(value) == len(kind.prefix) {
return appsValidationParamError(flagName, "%s must use %s IDs", flagName, kind.prefix).
WithHint("resolve names or emails to open IDs before calling role member commands")
}
return nil
}
func roleMemberKindForFlag(flagName string) (roleMemberKind, bool) {
if flagName == "--user-id" {
flagName = "--users"
}
for _, kind := range roleMemberKinds {
if kind.flagName == flagName {
return kind, true
}
}
return roleMemberKind{}, false
}
func roleMemberKindForType(memberType string) (roleMemberKind, bool) {
for _, kind := range roleMemberKinds {
if kind.memberType == memberType {
return kind, true
}
}
return roleMemberKind{}, false
}
func roleDisplayValue(value string) string {
value = validate.SanitizeForTerminal(value)
value = strings.NewReplacer("\n", " ", "\t", " ").Replace(value)
return strings.TrimSpace(value)
}
// withRoleErrorHint refines documented Spark role errors with command-specific
// recovery while preserving the typed error, numeric code, log_id, and any
// server-provided detail. Unknown codes retain the existing Apps fallback.
func withRoleErrorHint(err error, operation roleErrorOperation) error {
if err == nil {
return nil
}
problem, ok := errs.ProblemOf(err)
if !ok {
return err
}
hint := roleErrorHint(problem.Code, operation)
if hint == "" {
return withAppsHint(err, roleFallbackHint(operation))
}
existing := strings.TrimSpace(problem.Hint)
canonicalAPIHint := strings.TrimSpace(errclass.APIHint(problem.Subtype))
switch {
case existing == "", existing == canonicalAPIHint:
problem.Hint = hint
case !strings.Contains(existing, hint):
problem.Hint = existing + "; " + hint
}
return err
}
func roleFallbackHint(operation roleErrorOperation) string {
switch operation {
case roleOperationList:
return roleAppHint
case roleOperationCreate:
return roleCreateHint
case roleOperationMemberList, roleOperationMemberAdd, roleOperationMemberRemove:
return roleMemberHint
case roleOperationMatchList:
return roleMatchHint
default:
return roleItemHint
}
}
func roleErrorHint(code int, operation roleErrorOperation) string {
switch code {
case roleErrInvalidParameters:
return roleFallbackHint(operation)
case roleErrAdminRequired:
return "ask an app administrator to perform this operation or grant the calling user app-administrator access"
case roleErrManagerRequired:
return "ask an app administrator or app developer to perform this operation, or grant the calling user app-management access"
case roleErrInvalidRoleID:
if operation == roleOperationCreate {
return "omit --role-id to let the server generate one, or provide a role ID accepted by the role service"
}
case roleErrRoleNotFound:
if operation == roleOperationMatchList {
return "list the app's current roles and retry; role data used for this match may no longer be valid"
}
return roleItemHint
case roleErrRoleAlreadyExists:
if operation == roleOperationCreate {
return "choose a different --role-id or omit --role-id to let the server generate one"
}
case roleErrRoleLimitExceeded:
if operation == roleOperationCreate {
return "delete an unused app role before creating another role"
}
case roleErrInvalidRoleName:
if operation == roleOperationCreate || operation == roleOperationUpdate {
return "adjust --name to a non-empty value accepted by the role service"
}
case roleErrInvalidRoleDescription:
if operation == roleOperationCreate || operation == roleOperationUpdate {
return "adjust --description to a value accepted by the role service"
}
case roleErrUnsupportedMemberType:
if operation == roleOperationMemberList {
return "use --member-type user, department, or chat, or omit --member-type to list all member types"
}
case roleErrInvalidMemberID:
if operation == roleOperationMatchList {
return "resolve the target user to an open_id and retry with --user-id <open_id>"
}
if operation == roleOperationMemberAdd || operation == roleOperationMemberRemove {
return roleMemberHint
}
case roleErrUserLimitExceeded:
if operation == roleOperationMemberAdd {
return "reduce the users being added with --users, or remove unused user members before retrying"
}
case roleErrDepartmentLimitExceeded:
if operation == roleOperationMemberAdd {
return "reduce the departments being added with --departments, or remove unused department members before retrying"
}
case roleErrChatLimitExceeded:
if operation == roleOperationMemberAdd {
return "reduce the chats being added with --chats, or remove unused chat members before retrying"
}
}
return ""
}
func roleCollectionItem(item interface{}, collection string, index int) (map[string]interface{}, string, error) {
role, ok := item.(map[string]interface{})
if !ok {
return nil, "", invalidRoleCollectionResponse("%s item %d must be an object", collection, index)
}
rawRoleID, exists := role["role_id"]
roleID, stringOK := rawRoleID.(string)
roleID = strings.TrimSpace(roleID)
if !exists || !stringOK || roleID == "" {
return nil, "", invalidRoleCollectionResponse("%s item %d must contain a non-empty string role_id", collection, index)
}
rawName, exists := role["name"]
name, stringOK := rawName.(string)
if !exists || !stringOK || strings.TrimSpace(name) == "" {
return nil, "", invalidRoleCollectionResponse("%s item %d must contain a non-empty string name", collection, index)
}
return role, roleID, nil
}
func validateRoleCollection(items []interface{}, collection string) error {
for index, item := range items {
if _, _, err := roleCollectionItem(item, collection, index); err != nil {
return err
}
}
return nil
}
func invalidRoleCollectionResponse(format string, args ...interface{}) error {
return errs.NewInternalError(errs.SubtypeInvalidResponse, format, args...).
WithHint("retry the read; do not treat missing or malformed role data as an empty or complete result")
}
func buildRoleMemberGroups(usersCSV, departmentsCSV, chatsCSV string) (roleMemberGroups, error) {
users, err := splitRoleMemberCSV(usersCSV, "--users")
if err != nil {
return roleMemberGroups{}, err
}
departments, err := splitRoleMemberCSV(departmentsCSV, "--departments")
if err != nil {
return roleMemberGroups{}, err
}
chats, err := splitRoleMemberCSV(chatsCSV, "--chats")
if err != nil {
return roleMemberGroups{}, err
}
groups := roleMemberGroups{
Users: users,
Departments: departments,
Chats: chats,
}
total := len(groups.Users) + len(groups.Departments) + len(groups.Chats)
if total == 0 {
reason := "provide at least one of --users, --departments, or --chats"
return groups, appsValidationError("at least one of --users, --departments, or --chats is required").
WithParams(
appsInvalidParam("--users", reason),
appsInvalidParam("--departments", reason),
appsInvalidParam("--chats", reason),
).
WithHint("resolve names to IDs first, then pass --users open_id, --departments open_department_id, or --chats open_chat_id")
}
if total > maxRoleMembers {
return groups, appsValidationError("role members cannot exceed %d", maxRoleMembers).
WithParams(roleMemberLimitParams(groups)...).
WithHint(fmt.Sprintf("reduce the atomic request to at most %d members; the CLI does not split member writes automatically", maxRoleMembers))
}
return groups, nil
}
func buildRoleMemberBody(groups roleMemberGroups) map[string]interface{} {
body := map[string]interface{}{}
if len(groups.Users) > 0 {
body["users"] = groups.Users
}
if len(groups.Departments) > 0 {
body["departments"] = groups.Departments
}
if len(groups.Chats) > 0 {
body["chats"] = groups.Chats
}
return body
}
func roleMemberLimitParams(groups roleMemberGroups) []errs.InvalidParam {
reason := fmt.Sprintf("combined role member count exceeds %d", maxRoleMembers)
params := make([]errs.InvalidParam, 0, len(roleMemberKinds))
if len(groups.Users) > 0 {
params = append(params, appsInvalidParam("--users", reason))
}
if len(groups.Departments) > 0 {
params = append(params, appsInvalidParam("--departments", reason))
}
if len(groups.Chats) > 0 {
params = append(params, appsInvalidParam("--chats", reason))
}
return params
}

View File

@@ -0,0 +1,447 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/errclass"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func newRoleRCtx(t *testing.T, flagDefs map[string]string, flags map[string]string) (*common.RuntimeContext, *bytes.Buffer, *httpmock.Registry) {
t.Helper()
cfg := &core.CliConfig{
AppID: "test-app-" + strings.ToLower(t.Name()),
AppSecret: "test-secret",
Brand: core.BrandFeishu,
UserOpenId: "ou_test",
}
factory, stdoutBuf, _, reg := cmdutil.TestFactory(t, cfg)
cmd := &cobra.Command{Use: "test-role"}
cmd.SetContext(context.Background())
for name, typ := range flagDefs {
switch typ {
case "bool":
cmd.Flags().Bool(name, false, "")
case "int":
cmd.Flags().Int(name, 0, "")
case "string_array":
cmd.Flags().StringArray(name, nil, "")
default:
cmd.Flags().String(name, "", "")
}
}
for name, val := range flags {
if err := cmd.Flags().Set(name, val); err != nil {
t.Fatalf("set flag %q = %q: %v", name, val, err)
}
}
rctx := common.TestNewRuntimeContextForAPI(context.Background(), cmd, cfg, factory, core.AsUser)
return rctx, stdoutBuf, reg
}
func assertRoleValidationParam(t *testing.T, err error, param string) *errs.Problem {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("category = %q, want validation", problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("subtype = %q, want invalid_argument", problem.Subtype)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("err = %#v, want validation error", err)
}
if validation.Param != param {
t.Fatalf("param = %q, want %s", validation.Param, param)
}
return problem
}
func assertRoleValidationParams(t *testing.T, err error, params ...string) *errs.Problem {
t.Helper()
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %+v, want validation/invalid_argument", problem)
}
var validation *errs.ValidationError
if !errors.As(err, &validation) {
t.Fatalf("err = %#v, want validation error", err)
}
if validation.Param != "" {
t.Fatalf("param = %q, want omitted for multi-parameter constraint", validation.Param)
}
if len(validation.Params) != len(params) {
t.Fatalf("params = %#v, want %v", validation.Params, params)
}
for index, want := range params {
if validation.Params[index].Name != want || validation.Params[index].Reason == "" {
t.Fatalf("params[%d] = %#v, want name=%q with a reason", index, validation.Params[index], want)
}
}
return problem
}
func TestBuildRolePageParams_DefaultAndChanged(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"page-size": "int",
"page-token": "string",
}, map[string]string{})
params, err := buildRolePageParams(rctx)
if err != nil {
t.Fatalf("buildRolePageParams() = %v", err)
}
if params["limit"] != defaultRolePageSize || params["offset"] != 0 {
t.Fatalf("params = %#v, want limit=%d offset=0", params, defaultRolePageSize)
}
rctx, _, _ = newRoleRCtx(t, map[string]string{
"page-size": "int",
"page-token": "string",
}, map[string]string{"page-size": "20", "page-token": "40"})
params, err = buildRolePageParams(rctx)
if err != nil {
t.Fatalf("buildRolePageParams(changed) = %v", err)
}
if params["limit"] != 20 || params["offset"] != 40 {
t.Fatalf("params = %#v, want limit=20 offset=40", params)
}
}
func TestBuildRolePageParams_RejectsInvalidToken(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"page-size": "int",
"page-token": "string",
}, map[string]string{"page-token": "abc"})
_, err := buildRolePageParams(rctx)
assertRoleValidationParam(t, err, "--page-token")
}
func TestBuildRolePageParams_RejectsPageSizeOverMax(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"page-size": "int",
"page-token": "string",
}, map[string]string{"page-size": "101"})
_, err := buildRolePageParams(rctx)
assertRoleValidationParam(t, err, "--page-size")
}
func TestValidateOptionalRoleID(t *testing.T) {
for _, good := range []string{"", " role_001 ", "Role-ABC", "abc123", strings.Repeat("a", 64)} {
if err := validateOptionalRoleID(good); err != nil {
t.Fatalf("validateOptionalRoleID(%q) = %v", good, err)
}
}
for _, bad := range []string{"bad/role", "bad role", strings.Repeat("a", 65)} {
err := validateOptionalRoleID(bad)
problem := assertRoleValidationParam(t, err, "--role-id")
if !strings.Contains(problem.Hint, "omit --role-id") {
t.Fatalf("hint = %q, want create-specific omit guidance", problem.Hint)
}
}
}
func TestRoleFlagHelpersTrim(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
"role-id": "string",
}, map[string]string{"app-id": " app_1 ", "role-id": " role_1 "})
if got := roleAppID(rctx); got != "app_1" {
t.Fatalf("roleAppID() = %q, want app_1", got)
}
if got := roleID(rctx); got != "role_1" {
t.Fatalf("roleID() = %q, want role_1", got)
}
if err := validateRoleID(rctx); err != nil {
t.Fatalf("validateRoleID() = %v, want nil", err)
}
}
func TestValidateRoleAppIDRejectsEmpty(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
}, map[string]string{})
problem := assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
if problem.Message != "--app-id is required" {
t.Fatalf("message = %q, want --app-id is required", problem.Message)
}
if problem.Hint == "" {
t.Fatalf("hint is empty, want recovery guidance")
}
}
func TestValidateRoleAppIDRejectsPathSegmentUnsafeChars(t *testing.T) {
for _, appID := range []string{"app/bad", `app\bad`, "app bad", "app\u00a0bad", "app\nbad", "app\u0000bad"} {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
}, map[string]string{"app-id": appID})
assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
}
}
func TestValidateRoleAppIDRejectsLarkCredentialAppID(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
}, map[string]string{"app-id": "cli_app"})
assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
}
func TestValidateRoleAppIDRequiresMiaodaPrefix(t *testing.T) {
for _, appID := range []string{"app", "app_", "miaoda_123", "plain"} {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
}, map[string]string{"app-id": appID})
problem := assertRoleValidationParam(t, validateRoleAppID(rctx), "--app-id")
if !strings.Contains(problem.Message, "starting with app_") {
t.Fatalf("appID=%q message=%q, want app_ guidance", appID, problem.Message)
}
}
}
func TestValidateRoleIDRejectsInvalidRequiredRoleID(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
"role-id": "string",
}, map[string]string{"app-id": "app_x", "role-id": "bad/role"})
problem := assertRoleValidationParam(t, validateRoleID(rctx), "--role-id")
if strings.Contains(problem.Hint, "omit --role-id") || !strings.Contains(problem.Hint, "+role-list") {
t.Fatalf("hint = %q, want existing-role resolution guidance", problem.Hint)
}
}
func TestValidateRoleIDRejectsMissingRequiredRoleID(t *testing.T) {
rctx, _, _ := newRoleRCtx(t, map[string]string{
"app-id": "string",
"role-id": "string",
}, map[string]string{"app-id": "app_x"})
problem := assertRoleValidationParam(t, validateRoleID(rctx), "--role-id")
if problem.Message != "--role-id is required" {
t.Fatalf("message = %q, want --role-id is required", problem.Message)
}
}
func TestBuildRoleMemberGroupsAndBody(t *testing.T) {
groups, err := buildRoleMemberGroups(" ou_a,ou_b ", " od-a ", " oc_a ")
if err != nil {
t.Fatalf("buildRoleMemberGroups() = %v", err)
}
if len(groups.Users) != 2 || len(groups.Departments) != 1 || len(groups.Chats) != 1 {
t.Fatalf("groups = %#v", groups)
}
body := buildRoleMemberBody(groups)
assertJSONEquivalent(t, body, map[string]interface{}{
"users": []interface{}{"ou_a", "ou_b"},
"departments": []interface{}{"od-a"},
"chats": []interface{}{"oc_a"},
})
}
func TestBuildRoleMemberGroupsRejectsEmpty(t *testing.T) {
_, err := buildRoleMemberGroups(" , ", "", "")
assertRoleValidationParams(t, err, "--users", "--departments", "--chats")
}
func TestBuildRoleMemberGroupsRejectsInvalidMemberIDWithSourceParam(t *testing.T) {
tests := []struct {
name string
users string
departments string
chats string
wantParam string
}{
{name: "users slash", users: "ou/bad", wantParam: "--users"},
{name: "users email", users: "alice@example.com", wantParam: "--users"},
{name: "users wrong prefix", users: "user_123", wantParam: "--users"},
{name: "users prefix only", users: "ou_", wantParam: "--users"},
{name: "departments wrong prefix", departments: "ou_user", wantParam: "--departments"},
{name: "departments prefix only", departments: "od-", wantParam: "--departments"},
{name: "legacy departments prefix", departments: "od_department", wantParam: "--departments"},
{name: "chats wrong prefix", chats: "od-department", wantParam: "--chats"},
{name: "chats prefix only", chats: "oc_", wantParam: "--chats"},
{
name: "departments",
departments: "od-bad value",
wantParam: "--departments",
},
{
name: "chats",
chats: "oc?bad",
wantParam: "--chats",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := buildRoleMemberGroups(tt.users, tt.departments, tt.chats)
assertRoleValidationParam(t, err, tt.wantParam)
})
}
}
func TestBuildRoleMemberGroupsRejectsMoreThanMax(t *testing.T) {
users := make([]string, maxRoleMembers+1)
for i := range users {
users[i] = "ou_test"
}
_, err := buildRoleMemberGroups(strings.Join(users, ","), "", "")
assertRoleValidationParams(t, err, "--users")
}
func TestBuildRoleMemberGroupsRejectsMoreThanMaxOnlyChats(t *testing.T) {
chats := make([]string, maxRoleMembers+1)
for i := range chats {
chats[i] = "oc_test"
}
_, err := buildRoleMemberGroups("", "", strings.Join(chats, ","))
problem := assertRoleValidationParams(t, err, "--chats")
if !strings.Contains(problem.Message, "role members cannot exceed 100") {
t.Fatalf("message = %q, want role members limit", problem.Message)
}
if !strings.Contains(problem.Hint, "does not split") || !strings.Contains(problem.Hint, "atomic request") {
t.Fatalf("hint = %q, want no automatic batching guidance", problem.Hint)
}
}
func TestBuildRoleMemberGroupsOverflowNamesEveryContributingFlag(t *testing.T) {
users := strings.TrimSuffix(strings.Repeat("ou_user,", 60), ",")
chats := strings.TrimSuffix(strings.Repeat("oc_chat,", 41), ",")
_, err := buildRoleMemberGroups(users, "", chats)
assertRoleValidationParams(t, err, "--users", "--chats")
}
func TestRoleMemberKindsAreCompleteAndStable(t *testing.T) {
want := []roleMemberKind{
{memberType: "user", dataKey: "users", flagName: "--users", prefix: "ou_"},
{memberType: "department", dataKey: "departments", flagName: "--departments", prefix: "od-"},
{memberType: "chat", dataKey: "chats", flagName: "--chats", prefix: "oc_"},
}
if len(roleMemberKinds) != len(want) {
t.Fatalf("roleMemberKinds = %#v, want %#v", roleMemberKinds, want)
}
for index := range want {
if roleMemberKinds[index] != want[index] {
t.Fatalf("roleMemberKinds[%d] = %#v, want %#v", index, roleMemberKinds[index], want[index])
}
}
}
func TestRoleDisplayValueSanitizesAndFlattens(t *testing.T) {
got := roleDisplayValue(" Admin\n\x1b[31mred\x1b[0m\tvalue ")
if got != "Admin red value" {
t.Fatalf("roleDisplayValue() = %q, want flattened safe text", got)
}
}
func TestRoleNextPageToken(t *testing.T) {
if got := roleNextPageToken(40, 20, true); got != "60" {
t.Fatalf("roleNextPageToken(hasMore) = %q, want 60", got)
}
if got := roleNextPageToken(40, 20, false); got != "" {
t.Fatalf("roleNextPageToken(!hasMore) = %q, want empty", got)
}
}
func TestWithRoleErrorHintUsesDocumentedRecoveryAndPreservesEnvelope(t *testing.T) {
tests := []struct {
name string
code int
operation roleErrorOperation
wantHint string
forbid string
}{
{name: "invalid parameters", code: roleErrInvalidParameters, operation: roleOperationList, wantHint: roleAppHint},
{name: "administrator required", code: roleErrAdminRequired, operation: roleOperationList, wantHint: "app administrator"},
{name: "administrator or developer required", code: roleErrManagerRequired, operation: roleOperationGet, wantHint: "administrator or app developer"},
{name: "invalid create role id", code: roleErrInvalidRoleID, operation: roleOperationCreate, wantHint: "omit --role-id"},
{name: "role missing", code: roleErrRoleNotFound, operation: roleOperationGet, wantHint: "+role-list"},
{name: "stale match role", code: roleErrRoleNotFound, operation: roleOperationMatchList, wantHint: "may no longer be valid", forbid: "--role-id"},
{name: "duplicate role id", code: roleErrRoleAlreadyExists, operation: roleOperationCreate, wantHint: "different --role-id"},
{name: "role limit", code: roleErrRoleLimitExceeded, operation: roleOperationCreate, wantHint: "delete an unused app role"},
{name: "invalid role name", code: roleErrInvalidRoleName, operation: roleOperationUpdate, wantHint: "adjust --name"},
{name: "invalid role description", code: roleErrInvalidRoleDescription, operation: roleOperationUpdate, wantHint: "adjust --description"},
{name: "unsupported member type", code: roleErrUnsupportedMemberType, operation: roleOperationMemberList, wantHint: "user, department, or chat"},
{name: "invalid member id", code: roleErrInvalidMemberID, operation: roleOperationMemberAdd, wantHint: "member IDs"},
{name: "invalid match target", code: roleErrInvalidMemberID, operation: roleOperationMatchList, wantHint: "--user-id", forbid: "--role-id"},
{name: "user quota", code: roleErrUserLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the users"},
{name: "department quota", code: roleErrDepartmentLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the departments"},
{name: "chat quota", code: roleErrChatLimitExceeded, operation: roleOperationMemberAdd, wantHint: "reduce the chats"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := errclass.BuildAPIError(map[string]any{
"code": tt.code,
"msg": "role request failed",
"log_id": "log-role-hint",
}, errclass.ClassifyContext{Identity: "user"})
err = withRoleErrorHint(err, tt.operation)
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
if problem.Code != tt.code || problem.LogID != "log-role-hint" || problem.Retryable {
t.Fatalf("problem envelope changed: %+v", problem)
}
if !strings.Contains(problem.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", problem.Hint, tt.wantHint)
}
if tt.forbid != "" && strings.Contains(problem.Hint, tt.forbid) {
t.Fatalf("hint = %q, must not contain %q", problem.Hint, tt.forbid)
}
})
}
}
func TestWithRoleErrorHintPreservesServerDetail(t *testing.T) {
err := errclass.BuildAPIError(map[string]any{
"code": roleErrInvalidRoleName,
"msg": "invalid role name",
"error": map[string]any{
"details": []any{map[string]any{"value": "name exceeds the service limit"}},
},
}, errclass.ClassifyContext{Identity: "user"})
err = withRoleErrorHint(err, roleOperationCreate)
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
for _, want := range []string{"name exceeds the service limit", "adjust --name"} {
if !strings.Contains(problem.Hint, want) {
t.Fatalf("hint = %q, want %q", problem.Hint, want)
}
}
}
func TestWithRoleErrorHintPreservesAuthorizationDetail(t *testing.T) {
var err error = errs.NewPermissionError(errs.SubtypePermissionDenied, "administrator access required").
WithCode(roleErrAdminRequired).
WithHint("server detail: only owners may change this app")
err = withRoleErrorHint(err, roleOperationUpdate)
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %#v, want typed problem", err)
}
for _, want := range []string{"server detail: only owners", "ask an app administrator"} {
if !strings.Contains(problem.Hint, want) {
t.Fatalf("hint = %q, want %q", problem.Hint, want)
}
}
}

View File

@@ -0,0 +1,611 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"fmt"
"io"
"strings"
"text/tabwriter"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// AppsRoleMemberList lists members of an app role.
var AppsRoleMemberList = common.Shortcut{
Service: appsService,
Command: "+role-member-list",
Description: "List app role members",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +role-member-list --app-id <app_id> --role-id <role_id>",
"Example: lark-cli apps +role-member-list --app-id <app_id> --role-id <role_id> --member-type user",
"When only one member type is requested, pass --member-type user|department|chat instead of filtering the full response",
"--member-type returns only the selected member field; omitted fields are unknown, so omit the flag for pre/post-write baselines",
"--format table renders the CLI-native member_type/member_id table; this command has no --limit or --page-size flag",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
{Name: "member-type", Desc: "filter member type", Enum: []string{"user", "department", "chat"}},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleID(rctx); err != nil {
return err
}
_, err := buildRoleMemberListParams(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleMemberListParams; error is impossible here.
params, _ := buildRoleMemberListParams(rctx)
return common.NewDryRunAPI().
GET(roleMemberListURL(rctx)).
Desc("List app role members").
Params(params)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
params, err := buildRoleMemberListParams(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("GET", roleMemberListURL(rctx), params, nil)
memberType, _ := params["member_type"].(string)
if shouldRetryRoleMemberListWithoutFilter(err, memberType) {
fmt.Fprintln(rctx.IO().ErrOut, "warning: the server rejected chat member filtering; retried without the filter and returned only the chats field. Omit --member-type for a complete member baseline.")
data, err = rctx.CallAPITyped("GET", roleMemberListURL(rctx), nil, nil)
}
if err != nil {
return withRoleErrorHint(err, roleOperationMemberList)
}
data, err = normalizeRoleMemberListData(data, memberType)
if err != nil {
return err
}
if memberType != "" {
fmt.Fprintf(
rctx.IO().ErrOut,
"warning: --member-type=%s returns only the selected member field; omitted member fields are unknown. Omit --member-type for a complete member baseline.\n",
memberType,
)
}
out := roleMemberListOutputData(rctx, data)
rctx.OutFormat(out, nil, func(w io.Writer) {
renderRoleMemberListPretty(w, data)
})
return nil
},
}
// AppsRoleMemberAdd adds members to an app role.
var AppsRoleMemberAdd = common.Shortcut{
Service: appsService,
Command: "+role-member-add",
Description: "Add app role members",
Risk: "write",
Tips: []string{
"Example: lark-cli apps +role-member-add --app-id <app_id> --role-id <role_id> --users ou_x",
"Example: lark-cli apps +role-member-add --app-id <app_id> --role-id <role_id> --users ou_x,ou_y --departments od-x --chats oc_x",
"Resolve every name first, then add all resolved users (ou_), departments (od-), and chats (oc_) in one call using the three type-specific flags; if any resolution fails, stop without a partial write",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
{Name: "users", Desc: "comma-separated user open IDs; do not pass names or emails"},
{Name: "departments", Desc: "comma-separated open_department_id values"},
{Name: "chats", Desc: "comma-separated open_chat_id values"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleID(rctx); err != nil {
return err
}
_, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleMemberAddBody; error is impossible here.
body, _, _ := buildRoleMemberAddBody(rctx)
return common.NewDryRunAPI().
POST(roleMemberAddURL(rctx)).
Desc("Add app role members").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
body, _, err := buildRoleMemberAddBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", roleMemberAddURL(rctx), nil, body)
if err != nil {
return withRoleErrorHint(err, roleOperationMemberAdd)
}
data, err = normalizeRoleMemberMutationData(data)
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleMemberMutationPretty(w, data)
})
return nil
},
}
// AppsRoleMemberRemove removes members from an app role.
var AppsRoleMemberRemove = common.Shortcut{
Service: appsService,
Command: "+role-member-remove",
Description: "Remove app role members",
Risk: "high-risk-write",
Tips: []string{
"Example: lark-cli apps +role-member-remove --app-id <app_id> --role-id <role_id> --users ou_x --yes",
"Example: lark-cli apps +role-member-remove --app-id <app_id> --role-id <role_id> --all --yes",
"When the user names a member, resolve and verify that exact name before writing; if lookup fails, stop and never infer that the role's only current member is the target",
"--all clears members but does not delete the role; after a confirmed --all operation, use an unfiltered +role-member-list to verify users, departments, and chats are empty",
},
Scopes: []string{"spark:app:write"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "role-id", Desc: roleIDRequiredDesc, Required: true},
{Name: "users", Desc: "comma-separated user open IDs; do not pass names or emails"},
{Name: "departments", Desc: "comma-separated open_department_id values"},
{Name: "chats", Desc: "comma-separated open_chat_id values"},
{Name: "all", Type: "bool", Desc: "remove all members from the role"},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleID(rctx); err != nil {
return err
}
_, _, err := buildRoleMemberRemoveBody(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleMemberRemoveBody; error is impossible here.
body, _, _ := buildRoleMemberRemoveBody(rctx)
return common.NewDryRunAPI().
POST(roleMemberRemoveURL(rctx)).
Desc("Remove app role members").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
body, _, err := buildRoleMemberRemoveBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", roleMemberRemoveURL(rctx), nil, body)
if err != nil {
return withRoleErrorHint(err, roleOperationMemberRemove)
}
data, err = normalizeRoleMemberMutationData(data)
if err != nil {
return err
}
rctx.OutFormat(data, nil, func(w io.Writer) {
renderRoleMemberMutationPretty(w, data)
})
return nil
},
}
// AppsRoleMatchList lists roles matching a user in an app.
var AppsRoleMatchList = common.Shortcut{
Service: appsService,
Command: "+role-match-list",
Description: "List app roles matching a user",
Risk: "read",
Tips: []string{
"Example: lark-cli apps +role-match-list --app-id <app_id> --user-id <user_open_id>",
},
Scopes: []string{"spark:app:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "app-id", Desc: roleAppIDRequiredDesc, Required: true},
{Name: "user-id", Desc: roleUserIDRequiredDesc, Required: true},
},
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
if err := validateRoleAppID(rctx); err != nil {
return err
}
_, err := roleMatchTargetUserID(rctx)
return err
},
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
// Validate already ran and called buildRoleMatchListBody; error is impossible here.
body, _ := buildRoleMatchListBody(rctx)
return common.NewDryRunAPI().
POST(roleMatchListURL(rctx)).
Desc("List app role matches").
Body(body)
},
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
body, err := buildRoleMatchListBody(rctx)
if err != nil {
return err
}
data, err := rctx.CallAPITyped("POST", roleMatchListURL(rctx), nil, body)
if err != nil {
return withRoleErrorHint(err, roleOperationMatchList)
}
out, err := normalizeRoleMatchListData(data)
if err != nil {
return err
}
rctx.OutFormat(out, nil, func(w io.Writer) {
renderRoleMatchListPretty(w, common.GetSlice(out, "roles"))
})
return nil
},
}
func roleMemberListURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(roleMemberListPath,
validate.EncodePathSegment(roleAppID(rctx)),
validate.EncodePathSegment(roleID(rctx)),
)
}
func roleMemberAddURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(roleMemberAddPath,
validate.EncodePathSegment(roleAppID(rctx)),
validate.EncodePathSegment(roleID(rctx)),
)
}
func roleMemberRemoveURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(roleMemberRemovePath,
validate.EncodePathSegment(roleAppID(rctx)),
validate.EncodePathSegment(roleID(rctx)),
)
}
func roleMatchListURL(rctx *common.RuntimeContext) string {
return fmt.Sprintf(roleMatchListPath, validate.EncodePathSegment(roleAppID(rctx)))
}
func buildRoleMemberListParams(rctx *common.RuntimeContext) (map[string]interface{}, error) {
params := map[string]interface{}{}
if memberType := strings.TrimSpace(rctx.Str("member-type")); memberType != "" {
if _, ok := roleMemberKindForType(memberType); !ok {
return nil, appsValidationParamError("--member-type", "--member-type must be one of user, department, or chat").
WithHint("omit --member-type to list all member types")
}
params["member_type"] = memberType
}
return params, nil
}
func shouldRetryRoleMemberListWithoutFilter(err error, memberType string) bool {
if err == nil || memberType != "chat" {
return false
}
problem, ok := errs.ProblemOf(err)
if !ok {
return false
}
if problem.Code == roleErrUnsupportedMemberType || problem.Code == 400004040 {
return true
}
return problem.Code == 2 && strings.Contains(strings.ToLower(problem.Message), "member_type")
}
func normalizeRoleMemberListData(data map[string]interface{}, memberType string) (map[string]interface{}, error) {
if data == nil {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role member response data must be an object",
).WithHint("retry the complete member read; do not treat missing, null, or non-object data as an empty role")
}
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
// The role service uses an exact empty data object when the requested member
// view is empty. For a filtered request, that proves only the selected group
// is empty; non-selected groups must remain omitted rather than being
// synthesized as empty.
if len(data) == 0 {
if memberType != "" {
kind, _ := roleMemberKindForType(memberType)
out[kind.dataKey] = []string{}
return out, nil
}
for _, kind := range roleMemberKinds {
out[kind.dataKey] = []string{}
}
return out, nil
}
if memberType != "" {
selectedKind, _ := roleMemberKindForType(memberType)
values, err := parseRoleMemberIDs(data, selectedKind)
if err != nil {
return nil, err
}
for _, kind := range roleMemberKinds {
if kind.memberType != memberType {
delete(out, kind.dataKey)
}
}
out[selectedKind.dataKey] = values
return out, nil
}
for _, kind := range roleMemberKinds {
values, err := parseRoleMemberIDs(data, kind)
if err != nil {
return nil, err
}
out[kind.dataKey] = values
}
return out, nil
}
func parseRoleMemberIDs(data map[string]interface{}, kind roleMemberKind) ([]string, error) {
raw, exists := data[kind.dataKey]
if !exists {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role member response is missing %s",
kind.dataKey,
).WithHint("retry the member operation; do not treat a missing member group as empty")
}
items, ok := raw.([]interface{})
if !ok {
if stringItems, stringOK := raw.([]string); stringOK {
items = make([]interface{}, len(stringItems))
for index, value := range stringItems {
items[index] = value
}
} else {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role member response field %s must be an array of strings",
kind.dataKey,
).WithHint("retry the member operation; do not use malformed member data as a permission baseline")
}
}
values := make([]string, 0, len(items))
for index, item := range items {
value, ok := item.(string)
value = strings.TrimSpace(value)
if !ok || value == "" || !strings.HasPrefix(value, kind.prefix) || len(value) == len(kind.prefix) {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role member response field %s contains an invalid ID at index %d",
kind.dataKey,
index,
).WithHint("retry the member operation; expected open IDs with the documented member-type prefix")
}
values = append(values, value)
}
return values, nil
}
func normalizeRoleMemberMutationData(data map[string]interface{}) (map[string]interface{}, error) {
if data == nil {
return nil, nil
}
out := map[string]interface{}{}
for key, value := range data {
out[key] = value
}
for _, kind := range roleMemberKinds {
if _, exists := data[kind.dataKey]; !exists {
continue
}
values, err := parseRoleMemberIDs(data, kind)
if err != nil {
return nil, err
}
out[kind.dataKey] = values
}
return out, nil
}
func buildRoleMemberAddBody(rctx *common.RuntimeContext) (map[string]interface{}, roleMemberGroups, error) {
groups, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
if err != nil {
return nil, groups, err
}
return buildRoleMemberBody(groups), groups, nil
}
func buildRoleMemberRemoveBody(rctx *common.RuntimeContext) (map[string]interface{}, roleMemberGroups, error) {
if rctx.Bool("all") {
if hasExplicitRoleMemberFlags(rctx) {
return nil, roleMemberGroups{}, appsValidationError("--all cannot be used with --users, --departments, or --chats").
WithParams(roleMemberRemoveConflictParams(rctx)...).
WithHint("use --all by itself to clear every member, or pass explicit member IDs without --all")
}
return map[string]interface{}{"all": true}, roleMemberGroups{}, nil
}
if !hasExplicitRoleMemberFlags(rctx) {
reason := "provide member IDs or use --all"
return nil, roleMemberGroups{}, appsValidationError("specify members to remove with --users/--departments/--chats, or use --all to clear every member").
WithParams(
appsInvalidParam("--users", reason),
appsInvalidParam("--departments", reason),
appsInvalidParam("--chats", reason),
appsInvalidParam("--all", reason),
).
WithHint("pass specific member IDs (e.g. --users ou_x), or use --all to remove all members")
}
groups, err := buildRoleMemberGroups(rctx.Str("users"), rctx.Str("departments"), rctx.Str("chats"))
if err != nil {
return nil, groups, err
}
return buildRoleMemberBody(groups), groups, nil
}
func roleMemberRemoveConflictParams(rctx *common.RuntimeContext) []errs.InvalidParam {
reason := "cannot be combined with --all"
params := []errs.InvalidParam{appsInvalidParam("--all", "cannot be combined with explicit member flags")}
for _, kind := range roleMemberKinds {
if strings.TrimSpace(rctx.Str(strings.TrimPrefix(kind.flagName, "--"))) != "" {
params = append(params, appsInvalidParam(kind.flagName, reason))
}
}
return params
}
func hasExplicitRoleMemberFlags(rctx *common.RuntimeContext) bool {
return strings.TrimSpace(rctx.Str("users")) != "" ||
strings.TrimSpace(rctx.Str("departments")) != "" ||
strings.TrimSpace(rctx.Str("chats")) != ""
}
func buildRoleMatchListBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
targetUserID, err := roleMatchTargetUserID(rctx)
if err != nil {
return nil, err
}
return map[string]interface{}{"target_user_id": targetUserID}, nil
}
func roleMatchTargetUserID(rctx *common.RuntimeContext) (string, error) {
raw := strings.TrimSpace(rctx.Str("user-id"))
if raw == "" {
return "", appsValidationParamError("--user-id", "--user-id is required").
WithHint("resolve the user to open_id first, then pass --user-id <open_id>")
}
if err := validateMemberID(raw, "--user-id"); err != nil {
return "", err
}
return raw, nil
}
func roleMemberListOutputData(rctx *common.RuntimeContext, data map[string]interface{}) interface{} {
switch rctx.Format {
case "table", "csv", "ndjson":
return roleMemberRows(data)
default:
return data
}
}
func roleMemberRows(data map[string]interface{}) []interface{} {
rows := []interface{}{}
addRows := func(memberType string, values []string) {
for _, value := range values {
rows = append(rows, map[string]interface{}{
"member_type": memberType,
"member_id": value,
})
}
}
for _, kind := range roleMemberKinds {
addRows(kind.memberType, roleIDValues(data[kind.dataKey]))
}
return rows
}
func normalizeRoleMatchListData(data map[string]interface{}) (map[string]interface{}, error) {
rawRoles, exists := data["roles"]
roles, ok := rawRoles.([]interface{})
if !exists || !ok {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"role match response field roles must be an array",
).WithHint("retry the user-role lookup; do not treat a missing or malformed roles field as no matches")
}
if err := validateRoleCollection(roles, "role match response field roles"); err != nil {
return nil, err
}
out := map[string]interface{}{}
for k, v := range data {
out[k] = v
}
out["roles"] = roles
return out, nil
}
func renderRoleMemberListPretty(w io.Writer, data map[string]interface{}) {
renderRoleMemberGroupsPretty(w, data)
}
func renderRoleMemberGroupsPretty(w io.Writer, data map[string]interface{}) {
for _, kind := range roleMemberKinds {
value, exists := data[kind.dataKey]
if !exists {
continue
}
renderRoleMemberSection(w, kind.dataKey, roleIDValues(value))
}
}
func renderRoleMemberMutationPretty(w io.Writer, data map[string]interface{}) {
renderedGroup := false
for _, kind := range roleMemberKinds {
value, exists := data[kind.dataKey]
if !exists {
continue
}
renderRoleMemberSection(w, kind.dataKey, roleIDValues(value))
renderedGroup = true
}
if !renderedGroup {
fmt.Fprintln(w, "Role member update accepted; use +role-member-list to verify current members.")
}
}
func renderRoleMemberSection(w io.Writer, label string, values []string) {
if len(values) == 0 {
fmt.Fprintf(w, "%s: []\n", label)
return
}
fmt.Fprintf(w, "%s:\n", label)
for _, value := range values {
fmt.Fprintf(w, " - %s\n", roleDisplayValue(value))
}
}
func roleIDValues(value interface{}) []string {
switch items := value.(type) {
case []string:
out := make([]string, 0, len(items))
for _, item := range items {
if item = strings.TrimSpace(item); item != "" {
out = append(out, item)
}
}
return out
case []interface{}:
out := make([]string, 0, len(items))
for _, item := range items {
v, ok := item.(string)
if ok && strings.TrimSpace(v) != "" {
out = append(out, strings.TrimSpace(v))
}
}
return out
default:
return nil
}
}
func renderRoleMatchListPretty(w io.Writer, items []interface{}) {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ROLE ID\tNAME\tDESCRIPTION")
for _, item := range items {
role, ok := item.(map[string]interface{})
if !ok {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%s\n",
roleDisplayValue(firstNonEmpty(common.GetString(role, "role_id"), common.GetString(role, "id"))),
roleDisplayValue(common.GetString(role, "name")),
roleDisplayValue(common.GetString(role, "description")),
)
}
_ = tw.Flush()
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -17,6 +17,15 @@ func Shortcuts() []common.Shortcut {
AppsList,
AppsAccessScopeSet,
AppsAccessScopeGet,
AppsRoleList,
AppsRoleGet,
AppsRoleCreate,
AppsRoleUpdate,
AppsRoleDelete,
AppsRoleMemberList,
AppsRoleMemberAdd,
AppsRoleMemberRemove,
AppsRoleMatchList,
AppsHTMLPublish,
AppsInit,
AppsReleaseCreate,

View File

@@ -21,11 +21,12 @@ import (
// - 5 sessioncreate/list/get/stop/chat+ 1 session-messages-list
// - 8 openapi-keylist/get/create/update/enable/disable/delete/reset
// - 3 plugininstall/uninstall/list
// - 6 automationlist/get/create/update/enable/disable= 70。
func TestAppsShortcuts_Returns70(t *testing.T) {
// - 6 automationlist/get/create/update/enable/disable
// - 9 rolerole CRUD + role-member list/add/remove + role-match-list= 79。
func TestAppsShortcuts_Returns79(t *testing.T) {
got := Shortcuts()
if len(got) != 70 {
t.Fatalf("Shortcuts() returned %d entries, want 70", len(got))
if len(got) != 79 {
t.Fatalf("Shortcuts() returned %d entries, want 79", len(got))
}
}
@@ -89,6 +90,34 @@ func TestAppsShortcuts_IncludesSessionCommands(t *testing.T) {
}
}
// 确认 role 管理命令都已挂载,避免实现存在但 shortcut 漏注册。
func TestAppsShortcuts_IncludesRoleCommands(t *testing.T) {
want := map[string]bool{
"+role-list": false,
"+role-get": false,
"+role-create": false,
"+role-update": false,
"+role-delete": false,
"+role-member-list": false,
"+role-member-add": false,
"+role-member-remove": false,
"+role-match-list": false,
}
for _, sc := range Shortcuts() {
if _, ok := want[sc.Command]; ok {
want[sc.Command] = true
if sc.Hidden {
t.Errorf("%s must be visible", sc.Command)
}
}
}
for cmd, found := range want {
if !found {
t.Errorf("Shortcuts() missing %s", cmd)
}
}
}
// TestAppsGitCredentialHelper_IsNotAShortcut 确认 git credential helper 不作为 shortcut 暴露。
func TestAppsGitCredentialHelper_IsNotAShortcut(t *testing.T) {
for _, shortcut := range Shortcuts() {

View File

@@ -4,9 +4,11 @@
package base
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/httpmock"
)
@@ -676,6 +678,145 @@ func TestBaseDashboardBlockCreate_InvalidRollup(t *testing.T) {
}
}
// TestBaseDashboardBlockCreate_IllegalSortOrderType guards against a P1 where a
// non-string sort.order (123 / null / false) was silently coerced to "asc" and
// created a block with a tampered sort. A present-but-illegal order must now
// surface a typed validation error, never a silent default.
func TestBaseDashboardBlockCreate_IllegalSortOrderType(t *testing.T) {
for _, tc := range []struct {
name string
order string // raw JSON literal for the order value
}{
{"number", "123"},
{"null", "null"},
{"bool", "false"},
} {
t.Run(tc.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
dc := `{"table_name":"T","series":[{"field_name":"金额","rollup":"SUM"}],` +
`"group_by":[{"field_name":"状态","mode":"integrated","sort":{"type":"group","order":` + tc.order + `}}]}`
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
"--name", "Bad", "--type", "column", "--data-config", dc}
err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout)
if err == nil {
t.Fatalf("expected validation error for order=%s, got nil (stdout=%s)", tc.order, stdout.String())
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("expected *errs.ValidationError, got %T %v", err, err)
}
if ve.Category != errs.CategoryValidation || ve.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("category=%q subtype=%q, want validation/invalid_argument", ve.Category, ve.Subtype)
}
if ve.Param != "--data-config" {
t.Fatalf("param=%q, want --data-config", ve.Param)
}
if !strings.Contains(ve.Error(), "sort.order") {
t.Fatalf("error should name sort.order, got: %v", ve)
}
})
}
}
// TestBaseDashboardBlockCreate_MissingSortOrder pins the full create-path behavior
// when sort.order is absent: group/view are normalized to order:"asc" and succeed
// (matching the documented auto-fill), while value has no safe default and must
// surface a typed validation error. These run end-to-end (Validate → normalize →
// validate), so reverting the normalize/validate change flips a case and fails.
func TestBaseDashboardBlockCreate_MissingSortOrder(t *testing.T) {
dc := func(sortType string) string {
return `{"table_name":"T","series":[{"field_name":"金额","rollup":"SUM"}],` +
`"group_by":[{"field_name":"状态","mode":"integrated","sort":{"type":"` + sortType + `"}}]}`
}
// group / view: absent order is auto-filled with "asc" and the request goes through.
for _, sortType := range []string{"group", "view"} {
t.Run(sortType+" defaults to asc", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
"--name", "OK", "--type", "column", "--data-config", dc(sortType),
"--dry-run", "--format", "pretty"}
if err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"order":"asc"`) {
t.Fatalf("expected normalized order:asc for type=%s, stdout=%s", sortType, got)
}
})
}
// value: no meaningful default direction, so a missing order is a typed error.
t.Run("value requires explicit order", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
args := []string{"+dashboard-block-create", "--base-token", "app_x", "--dashboard-id", "dsh_1",
"--name", "Bad", "--type", "column", "--data-config", dc("value")}
err := runShortcut(t, BaseDashboardBlockCreate, args, factory, stdout)
if err == nil {
t.Fatalf("expected validation error for value sort missing order, got nil (stdout=%s)", stdout.String())
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected validation/invalid_argument problem, got %T %v", err, err)
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Param != "--data-config" {
t.Fatalf("expected param --data-config, got %T %v", err, err)
}
if !strings.Contains(ve.Error(), "sort.order 缺失") {
t.Fatalf("error should report missing order, got: %v", ve)
}
})
}
// TestNormalizeDataConfigSortOrder pins the normalization contract for sort.order:
// only a truly absent key gets the "asc" default; a present illegal value is left
// untouched so validation can reject it; a valid string is lower-cased.
func TestNormalizeDataConfigSortOrder(t *testing.T) {
sortOf := func(cfg map[string]interface{}) map[string]interface{} {
gb := cfg["group_by"].([]interface{})
return gb[0].(map[string]interface{})["sort"].(map[string]interface{})
}
newCfg := func(sort map[string]interface{}) map[string]interface{} {
return map[string]interface{}{
"table_name": "T",
"series": []interface{}{map[string]interface{}{"field_name": "v", "rollup": "sum"}},
"group_by": []interface{}{map[string]interface{}{"field_name": "g", "sort": sort}},
}
}
t.Run("absent order defaults to asc for group", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group"}))
if got := sortOf(out)["order"]; got != "asc" {
t.Fatalf("order=%v, want asc", got)
}
})
t.Run("absent order not defaulted for value", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "value"}))
if _, has := sortOf(out)["order"]; has {
t.Fatalf("value sort must not get a defaulted order: %v", sortOf(out))
}
})
t.Run("valid string lower-cased", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group", "order": "DESC"}))
if got := sortOf(out)["order"]; got != "desc" {
t.Fatalf("order=%v, want desc", got)
}
})
t.Run("illegal number not coerced", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "group", "order": float64(123)}))
if got := sortOf(out)["order"]; got != float64(123) {
t.Fatalf("order=%v (type %T), want untouched 123", got, got)
}
})
t.Run("illegal nil not coerced", func(t *testing.T) {
out := normalizeDataConfig(newCfg(map[string]interface{}{"type": "view", "order": nil}))
got, has := sortOf(out)["order"]
if !has || got != nil {
t.Fatalf("order=%v has=%v, want present nil (untouched)", got, has)
}
})
}
// ── Text Block Tests ────────────────────────────────────────────────
// TestBaseDashboardBlockExecuteCreate_TextType tests creating text blocks with markdown content.

View File

@@ -117,6 +117,14 @@ func TestDryRunRecordOps(t *testing.T) {
)
assertDryRunContains(t, dryRunRecordList(ctx, listRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "offset=0", "limit=200", "view_id=viw_1", "field_id=Name", "field_id=Age")
listFieldNamesAliasRT := newBaseTestRuntimeWithSlices(
map[string]string{"base-token": "app_x", "table-id": "tbl_1"},
map[string][]string{"field-names": {"Name", "Age"}},
nil,
map[string]int{"limit": 20},
)
assertDryRunContains(t, dryRunRecordList(ctx, listFieldNamesAliasRT), "GET /open-apis/base/v3/bases/app_x/tables/tbl_1/records", "limit=20", "field_id=Name", "field_id=Age")
filteredListRT := newBaseTestRuntimeWithArrays(
map[string]string{
"base-token": "app_x",

View File

@@ -1296,6 +1296,29 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list field names alias", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "field_id=Name&field_id=Age&limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name", "Age"},
"record_id_list": []interface{}{"rec_alias"},
"data": []interface{}{[]interface{}{"Alice", 18}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--field-names", "Name,Age", "--format", "json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"rec_alias"`) || !strings.Contains(got, `"Alice"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list json format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1320,6 +1343,30 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list json alias", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "limit=1&offset=0",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"fields": []interface{}{"Name"},
"field_id_list": []interface{}{"fld_name"},
"record_id_list": []interface{}{"rec_alias"},
"data": []interface{}{[]interface{}{"Carol"}},
"total": 1,
},
},
})
if err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--limit", "1", "--json"}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"record_id_list"`) || !strings.Contains(got, `"Carol"`) || !strings.Contains(got, `"rec_alias"`) {
t.Fatalf("stdout=%s", got)
}
})
t.Run("list markdown format", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -1576,6 +1623,14 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("list field ids and field names alias are mutually exclusive", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "Name", "--field-names", "Age"}, factory, stdout)
if err == nil || !strings.Contains(err.Error(), "--field-id and --field-names are mutually exclusive") {
t.Fatalf("err=%v", err)
}
})
t.Run("list legacy fields flag rejected in dry-run", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, BaseRecordList, []string{"+record-list", "--base-token", "app_x", "--table-id", "tbl_x", "--fields", "Name", "--dry-run"}, factory, stdout)

View File

@@ -28,6 +28,14 @@ func newBaseTestRuntime(stringFlags map[string]string, boolFlags map[string]bool
}
func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, stringArrayFlags, nil, boolFlags, intFlags)
}
func newBaseTestRuntimeWithSlices(stringFlags map[string]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
return newBaseTestRuntimeWithArraysAndSlices(stringFlags, nil, stringSliceFlags, boolFlags, intFlags)
}
func newBaseTestRuntimeWithArraysAndSlices(stringFlags map[string]string, stringArrayFlags map[string][]string, stringSliceFlags map[string][]string, boolFlags map[string]bool, intFlags map[string]int) *common.RuntimeContext {
cmd := &cobra.Command{Use: "test"}
for name := range stringFlags {
cmd.Flags().String(name, "", "")
@@ -35,6 +43,9 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag
for name := range stringArrayFlags {
cmd.Flags().StringArray(name, nil, "")
}
for name := range stringSliceFlags {
cmd.Flags().StringSlice(name, nil, "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
@@ -50,6 +61,11 @@ func newBaseTestRuntimeWithArrays(stringFlags map[string]string, stringArrayFlag
_ = cmd.Flags().Set(name, value)
}
}
for name, values := range stringSliceFlags {
for _, value := range values {
_ = cmd.Flags().Set(name, value)
}
}
for name, value := range boolFlags {
if value {
_ = cmd.Flags().Set(name, "true")
@@ -545,6 +561,8 @@ func TestBaseDashboardHelpGuidesAgents(t *testing.T) {
"not table_id or field_id",
"dashboard-block-data-config.md as the SSOT",
"do not invent data_config from natural language",
"set the intended group_by.sort in the initial create request",
"do not create first and then issue a second update",
"sequentially",
},
},
@@ -825,6 +843,7 @@ func TestBaseRecordWriteHelpGuidesAgents(t *testing.T) {
"may use null for empty cells",
"use +field-list to confirm real writable fields",
"Batch create supports max 200 rows per call",
"do not immediately +record-list the same table",
"CellValue happy path: text/phone/url",
`ID-based CellValue: user/group/link fields use arrays like [{"id":"ou_xxx"}]`,
"lark-base-cell-value.md",

View File

@@ -23,7 +23,7 @@ var BaseDashboardArrange = common.Shortcut{
{Name: "user-id-type", Desc: "user ID type: open_id / union_id / user_id"},
},
Tips: []string{
"Server-side smart layout is not deterministic or position-specific; use only when the user asks to arrange or beautify a dashboard.",
"Server-side smart layout is not deterministic or position-specific; use only when the user asks to arrange or beautify a dashboard, or to tidy up a dashboard created from scratch in this session.",
},
DryRun: dryRunDashboardArrange,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -27,7 +27,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
{Name: "type", Desc: "block type: column(柱状图)|bar(条形图)|line(折线图)|pie(饼图)|ring(环形图)|area(面积图)|combo(组合图)|scatter(散点图)|funnel(漏斗图)|wordCloud(词云)|radar(雷达图)|statistics(指标卡)|text(文本). Read dashboard-block-data-config.md before creating.", Required: true},
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation and normalization; send data_config as-is"},
},
Tips: []string{
`lark-cli base +dashboard-block-create --base-token <base_token> --dashboard-id <dashboard_id> --name "Order Count" --type statistics --data-config '{"table_name":"Orders","count_all":true}'`,
@@ -35,6 +35,7 @@ var BaseDashboardBlockCreate = common.Shortcut{
"Before creating data-backed blocks, use +table-list and +field-list to confirm real table and field names.",
"data_config uses table and field names, not table_id or field_id.",
"Read dashboard-block-data-config.md as the SSOT for chart templates, filters, metric rules, and type-specific fields; do not invent data_config from natural language.",
"For funnel/stage charts backed by ordered helper data, set the intended group_by.sort in the initial create request; do not create first and then issue a second update just to fix sorting.",
"Record the returned block_id; block update/delete/get-data commands need it.",
"Create dashboard blocks sequentially; do not parallelize multiple block creates for the same dashboard.",
},

View File

@@ -20,6 +20,7 @@ var BaseDashboardBlockGetData = common.Shortcut{
Flags: []common.Flag{
baseTokenFlag(true),
blockIDFlag(true),
{Name: "dashboard-id", Desc: "hidden compatibility flag accepted by dashboard block commands; ignored by get-data", Hidden: true},
},
Tips: []string{
"lark-cli base +dashboard-block-get-data --base-token <base_token> --block-id <block_id>",

View File

@@ -26,7 +26,7 @@ var BaseDashboardBlockUpdate = common.Shortcut{
{Name: "name", Desc: "new block name"},
{Name: "data-config", Desc: "data_config JSON object; read dashboard-block-data-config.md for the SSOT"},
{Name: "user-id-type", Desc: "user ID type for user fields in filters: open_id / union_id / user_id"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation"},
{Name: "no-validate", Type: "bool", Desc: "skip local data_config validation and normalization; send data_config as-is"},
},
Tips: []string{
`lark-cli base +dashboard-block-update --base-token <base_token> --dashboard-id <dashboard_id> --block-id <block_id> --name "Total Sales"`,

View File

@@ -1038,11 +1038,23 @@ func normalizeDataConfig(cfg map[string]interface{}) map[string]interface{} {
m["mode"] = strings.ToLower(strings.TrimSpace(md))
}
if sub, ok := m["sort"].(map[string]interface{}); ok {
sortType := ""
if t, ok := sub["type"].(string); ok {
sub["type"] = strings.ToLower(strings.TrimSpace(t))
sortType = strings.ToLower(strings.TrimSpace(t))
sub["type"] = sortType
}
if o, ok := sub["order"].(string); ok {
sub["order"] = strings.ToLower(strings.TrimSpace(o))
// Only lowercase a string order; leave a present-but-non-string
// order untouched so validateBlockDataConfig can reject it
// instead of it being silently coerced below.
_, hasOrderKey := sub["order"]
orderStr, orderIsString := sub["order"].(string)
if orderIsString {
sub["order"] = strings.ToLower(strings.TrimSpace(orderStr))
}
// Default only when the order key is truly absent. A present
// key (even an illegal type/value) must survive to validation.
if !hasOrderKey && (sortType == "group" || sortType == "view") {
sub["order"] = "asc"
}
m["sort"] = sub
}
@@ -1126,12 +1138,16 @@ func validateBlockDataConfig(blockType string, cfg map[string]interface{}) []str
if sub, ok := m["sort"].(map[string]interface{}); ok {
t, _ := sub["type"].(string)
t = strings.ToLower(strings.TrimSpace(t))
o, _ := sub["order"].(string)
o = strings.ToLower(strings.TrimSpace(o))
if t != "group" && t != "value" && t != "view" {
errs = append(errs, fmt.Sprintf("group_by[%d].sort.type 仅支持 group|value|view", i))
}
if o != "asc" && o != "desc" {
orderRaw, hasOrder := sub["order"]
o, orderIsString := orderRaw.(string)
o = strings.ToLower(strings.TrimSpace(o))
switch {
case !hasOrder:
errs = append(errs, fmt.Sprintf("group_by[%d].sort.order 缺失sort 存在时必须设置 order 为 asc 或 desc例如 \"sort\":{\"type\":\"group\",\"order\":\"asc\"}", i))
case !orderIsString || (o != "asc" && o != "desc"):
errs = append(errs, fmt.Sprintf("group_by[%d].sort.order 仅支持 asc|desc", i))
}
}
@@ -1178,5 +1194,5 @@ func formatDataConfigErrors(problems []string) error {
if len(problems) == 0 {
return nil
}
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- "))
return errs.NewValidationError(errs.SubtypeInvalidArgument, "data_config 校验失败:\n- %s\n参考: skills/lark-base/references/dashboard-block-data-config.md", strings.Join(problems, "\n- ")).WithParam("--data-config")
}

View File

@@ -25,6 +25,7 @@ var BaseRecordBatchCreate = common.Shortcut{
"Happy path fields: fields is the column order; rows is an array of row arrays; each row must match fields order and may use null for empty cells.",
"Before writing, use +field-list to confirm real writable fields; do not write system fields, formula, lookup, or attachment fields as normal CellValue.",
"Batch create supports max 200 rows per call.",
"After batch-creating known helper rows, use the returned record IDs and your submitted rows; do not immediately +record-list the same table unless you need server-normalized formula/lookup values or failure diagnosis.",
"Use the record-batch-create guide for command limits and edge cases.",
}, recordCellValueHappyPathTips...),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -21,6 +21,7 @@ var BaseRecordList = common.Shortcut{
baseTokenFlag(true),
tableRefFlag(true),
recordListFieldRefFlag(),
recordListFieldNamesAliasFlag(),
recordListViewRefFlag(),
recordFilterFlag(),
recordSortFlag(),
@@ -43,6 +44,9 @@ var BaseRecordList = common.Shortcut{
"Use --field-id repeatedly to keep output small and aligned with the task.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateRecordListFieldAlias(runtime); err != nil {
return err
}
if err := validateRecordReadFormat(runtime); err != nil {
return err
}
@@ -75,6 +79,15 @@ func recordListFieldRefFlag() common.Flag {
return flag
}
func recordListFieldNamesAliasFlag() common.Flag {
return common.Flag{
Name: "field-names",
Type: "string_slice",
Desc: "hidden alias for --field-id; accepts comma-separated field names",
Hidden: true,
}
}
func recordListViewRefFlag() common.Flag {
flag := viewRefFlag(false)
flag.Desc = "view ID or name; omit for reading all table records, or set to read a user-specified or temporary filtered/sorted view"
@@ -89,3 +102,10 @@ func recordReadFormatFlag() common.Flag {
Desc: "output format: markdown (default) | json",
}
}
func validateRecordListFieldAlias(runtime *common.RuntimeContext) error {
if runtime.Changed("field-id") && runtime.Changed("field-names") {
return baseFlagErrorf("--field-id and --field-names are mutually exclusive; use --field-id")
}
return nil
}

View File

@@ -376,6 +376,9 @@ func validateRecordJSON(runtime *common.RuntimeContext) error {
}
func recordListFields(runtime *common.RuntimeContext) []string {
if runtime.Changed("field-names") {
return runtime.StrSlice("field-names")
}
return runtime.StrArray("field-id")
}

View File

@@ -22,15 +22,23 @@ func GetString(m map[string]interface{}, keys ...string) string {
// GetFloat safely extracts a float64 (the default JSON number type).
func GetFloat(m map[string]interface{}, keys ...string) float64 {
f, _ := GetFloatOK(m, keys...)
return f
}
// GetFloatOK extracts a float64 and reports whether the field was present and
// numeric. Use it for protocol discriminators where silently turning malformed
// input into zero could misclassify a response as successful.
func GetFloatOK(m map[string]interface{}, keys ...string) (float64, bool) {
if len(keys) == 0 {
return 0
return 0, false
}
v := navigate(m, keys[:len(keys)-1])
if v == nil {
return 0
return 0, false
}
f, _ := util.ToFloat64(v[keys[len(keys)-1]])
return f
f, ok := util.ToFloat64(v[keys[len(keys)-1]])
return f, ok
}
// GetInt safely extracts an int, accepting both in-memory ints and JSON-style float64 values.

View File

@@ -64,6 +64,24 @@ func TestGetFloat(t *testing.T) {
}
}
func TestGetFloatOKDistinguishesMalformedValuesFromZero(t *testing.T) {
t.Parallel()
m := map[string]interface{}{
"zero": float64(0),
"null": nil,
"string": "0",
}
if got, ok := GetFloatOK(m, "zero"); !ok || got != 0 {
t.Fatalf("GetFloatOK(zero) = (%v, %t), want (0, true)", got, ok)
}
for _, key := range []string{"null", "string", "missing"} {
if got, ok := GetFloatOK(m, key); ok || got != 0 {
t.Fatalf("GetFloatOK(%s) = (%v, %t), want (0, false)", key, got, ok)
}
}
}
func TestGetInt(t *testing.T) {
m := map[string]interface{}{
"count": 42,

View File

@@ -32,14 +32,15 @@ type driveDeleteSpec struct {
FileType string
}
// DriveDelete deletes a Drive file or folder and handles the async task
// polling required by folder deletes.
// 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.
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"},
Scopes: []string{"space:document:delete", "drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "file or folder token to delete", Required: true},
@@ -63,13 +64,11 @@ var DriveDelete = common.Shortcut{
dry.DELETE("/open-apis/drive/v1/files/:file_token").
Desc("[1] Delete file/folder").
Set("file_token", spec.FileToken).
Params(map[string]interface{}{"type": spec.FileType})
Params(driveDeleteParams(spec))
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>"))
}
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>"))
return dry
},
@@ -84,56 +83,59 @@ var DriveDelete = common.Shortcut{
data, err := runtime.CallAPITyped(
"DELETE",
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)),
map[string]interface{}{"type": spec.FileType},
driveDeleteParams(spec),
nil,
)
if err != nil {
return err
}
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(),
taskID := common.GetString(data, "task_id")
if taskID == "" {
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, "Folder 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
}
runtime.Out(map[string]interface{}{
"deleted": true,
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(),
"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, "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
},
}
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,6 +7,7 @@ import (
"bytes"
"context"
"encoding/json"
"net/http"
"strings"
"testing"
@@ -32,16 +33,16 @@ func TestValidateDriveDeleteSpecRejectsWiki(t *testing.T) {
}
}
func TestDriveDeleteDryRunFolderIncludesTaskCheckParams(t *testing.T) {
func TestDriveDeleteDryRunIncludesAsyncAndTaskCheckParams(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", "fld_src"); err != nil {
if err := cmd.Flags().Set("file-token", "docx_src"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", "folder"); err != nil {
if err := cmd.Flags().Set("type", "docx"); err != nil {
t.Fatalf("set --type: %v", err)
}
@@ -71,14 +72,36 @@ func TestDriveDeleteDryRunFolderIncludesTaskCheckParams(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"] != "folder" {
if got.API[0].Params["type"] != "docx" {
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())
@@ -97,6 +120,63 @@ 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",
@@ -117,23 +197,33 @@ func TestDriveDeleteFileSuccess(t *testing.T) {
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"deleted": true`)) {
t.Fatalf("stdout missing deleted=true: %s", stdout.String())
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(`"file_token": "file_token_test"`)) {
t.Fatalf("stdout missing file token: %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())
}
}
func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
func TestDriveDeleteTaskCheckOutcomes(t *testing.T) {
tests := []struct {
name string
fileType string
fileToken string
taskCheckBody map[string]interface{}
wantErrContains string
wantStdout []string
}{
{
name: "success",
name: "docx success",
fileType: "docx",
fileToken: "docx_src",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
@@ -145,7 +235,9 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
},
},
{
name: "timeout",
name: "folder timeout",
fileType: "folder",
fileToken: "fld_src",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "process"},
@@ -157,15 +249,19 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
},
},
{
name: "failed",
name: "folder failed",
fileType: "folder",
fileToken: "fld_src",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "fail"},
},
wantErrContains: "folder task failed",
wantErrContains: "drive task failed",
},
{
name: "task_check error",
name: "docx task_check error",
fileType: "docx",
fileToken: "docx_src",
taskCheckBody: map[string]interface{}{
"code": 1061001,
"msg": "internal error",
@@ -179,7 +275,7 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/fld_src",
URL: "/open-apis/drive/v1/files/" + tt.fileToken,
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_123"},
@@ -195,8 +291,8 @@ func TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", "fld_src",
"--type", "folder",
"--file-token", tt.fileToken,
"--type", tt.fileType,
"--yes",
"--as", "bot",
}, f, stdout)
@@ -222,3 +318,66 @@ func TestDriveDeleteFolderTaskCheckOutcomes(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 folder move/delete operations.
// /drive/v1/files/task_check for async Drive 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 folder delete can return the shorter
// backends return "failed", while 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
// folder move or delete task.
// Drive 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, "Folder task completed successfully.\n")
fmt.Fprintf(runtime.IO().ErrOut, "Drive task completed successfully.\n")
return status, true, nil
}
if status.Failed() {
return status, false, errs.NewAPIError(errs.SubtypeServerError, "folder task failed")
return status, false, errs.NewAPIError(errs.SubtypeServerError, "drive task failed")
}
}

View File

@@ -15,12 +15,20 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
const (
// These are fixed backend wire values for the Wiki-to-Drive task. Keep
// them unchanged even though the CLI scenario uses wiki_move_to_drive.
wikiMoveToDriveTaskType = "move_wiki_to_docs"
wikiMoveToDriveResultKey = "move_wiki_to_docs_result"
)
// DriveTaskResult exposes a unified read path for the async task types produced
// by Drive import, export, folder move/delete, wiki move, and wiki delete-space flows.
// by Drive import, export, file/folder move/delete, wiki move, wiki move-to-drive,
// and wiki delete flows.
var DriveTaskResult = common.Shortcut{
Service: "drive",
Command: "+task_result",
Description: "Poll async task result for import, export, drive move/delete, wiki move, wiki delete-space, or wiki delete-node operations",
Description: "Poll async task result for import, export, drive move/delete, wiki move, wiki move-to-drive, or wiki delete operations",
Risk: "read",
// This shortcut multiplexes multiple backend APIs with different scope
// requirements, so scenario-specific prechecks are handled in Validate.
@@ -28,22 +36,23 @@ var DriveTaskResult = common.Shortcut{
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "ticket", Desc: "async task ticket (for import/export tasks)", Required: false},
{Name: "task-id", Desc: "async task ID (for drive task_check, wiki_move, wiki_delete_space, or wiki_delete_node tasks)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, wiki_delete_space, or wiki_delete_node", Required: true},
{Name: "task-id", Desc: "async task ID (for drive task_check and all wiki task scenarios)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, task_check, wiki_move, wiki_move_to_drive, wiki_delete_space, or wiki_delete_node", Required: true},
{Name: "file-token", Desc: "source document token used for export task status lookup", Required: false},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
scenario := strings.ToLower(runtime.Str("scenario"))
validScenarios := map[string]bool{
"import": true,
"export": true,
"task_check": true,
"wiki_move": true,
"wiki_delete_space": true,
"wiki_delete_node": true,
"import": true,
"export": true,
"task_check": true,
"wiki_move": true,
"wiki_move_to_drive": true,
"wiki_delete_space": true,
"wiki_delete_node": true,
}
if !validScenarios[scenario] {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_delete_space, wiki_delete_node", scenario).WithParam("--scenario")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move, wiki_move_to_drive, wiki_delete_space, wiki_delete_node", scenario).WithParam("--scenario")
}
// Validate required params based on scenario
@@ -55,7 +64,7 @@ var DriveTaskResult = common.Shortcut{
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--ticket")
}
case "task_check", "wiki_move", "wiki_delete_space", "wiki_delete_node":
case "task_check", "wiki_move", "wiki_move_to_drive", "wiki_delete_space", "wiki_delete_node":
if runtime.Str("task-id") == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required for %s scenario", scenario).WithParam("--task-id")
}
@@ -97,13 +106,18 @@ 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 move/delete folder task status").
Desc("[1] Query Drive file/folder move/delete task status").
Params(driveTaskCheckParams(taskID))
case "wiki_move":
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[1] Query wiki move task result").
Set("task_id", taskID).
Params(map[string]interface{}{"task_type": "move"})
case "wiki_move_to_drive":
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[1] Query wiki move-to-drive task result").
Set("task_id", taskID).
Params(map[string]interface{}{"task_type": wikiMoveToDriveTaskType})
case "wiki_delete_space":
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[1] Query wiki delete-space task result").
@@ -140,6 +154,8 @@ var DriveTaskResult = common.Shortcut{
result, err = queryTaskCheck(runtime, taskID)
case "wiki_move":
result, err = queryWikiMoveTask(runtime, taskID)
case "wiki_move_to_drive":
result, err = queryWikiMoveToDriveTask(runtime, taskID)
case "wiki_delete_space":
result, err = queryWikiDeleteSpaceTask(runtime, taskID)
case "wiki_delete_node":
@@ -209,7 +225,7 @@ func queryExportTask(runtime *common.RuntimeContext, ticket, fileToken string) (
}, nil
}
// queryTaskCheck returns the normalized status of a folder move/delete task.
// queryTaskCheck returns the normalized status of a Drive file/folder move/delete task.
func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
status, err := getDriveTaskCheckStatus(runtime, taskID)
if err != nil {
@@ -244,7 +260,7 @@ func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeC
switch scenario {
case "import", "export", "task_check":
required = []string{"drive:drive.metadata:readonly"}
case "wiki_move", "wiki_delete_space", "wiki_delete_node":
case "wiki_move", "wiki_move_to_drive", "wiki_delete_space", "wiki_delete_node":
required = []string{"wiki:space:read"}
}
@@ -486,6 +502,75 @@ func appendWikiMoveNodeFields(out, node map[string]interface{}) {
out["has_child"] = common.GetBool(node, "has_child")
}
// queryWikiMoveToDriveTask returns the normalized status and final Drive
// resource fields for wiki +move-to-drive. The task endpoint uses a dedicated
// result object with numeric status codes: 0 success, 1 processing, -1 failure.
func queryWikiMoveToDriveTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "%s", err).WithParam("--task-id").WithCause(err)
}
data, err := runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": wikiMoveToDriveTaskType},
nil,
)
if err != nil {
return nil, err
}
task := common.GetMap(data, "task")
if task == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
result := common.GetMap(task, wikiMoveToDriveResultKey)
if result == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing %s", wikiMoveToDriveResultKey)
}
statusCode, ok := common.GetFloatOK(result, "status")
if !ok {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response has missing or non-numeric %s.status", wikiMoveToDriveResultKey)
}
if statusCode != -1 && statusCode != 0 && statusCode != 1 {
return nil, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"wiki task response has unsupported %s.status: %v",
wikiMoveToDriveResultKey,
statusCode,
)
}
resolvedTaskID := common.GetString(task, "task_id")
if resolvedTaskID == "" {
resolvedTaskID = taskID
}
status := int(statusCode)
statusMsg := strings.TrimSpace(common.GetString(result, "status_msg"))
if statusMsg == "" {
switch {
case status == 0:
statusMsg = "success"
case status < 0:
statusMsg = "failure"
default:
statusMsg = "processing"
}
}
return map[string]interface{}{
"scenario": "wiki_move_to_drive",
"task_id": resolvedTaskID,
"ready": status == 0,
"failed": status < 0,
"status": status,
"status_msg": statusMsg,
"obj_token": common.GetString(result, "obj_token"),
"obj_type": common.GetString(result, "obj_type"),
"url": common.GetString(result, "url"),
}, nil
}
// queryWikiDeleteSpaceTask returns the normalized status of an async wiki
// delete-space task. The backend reports a single delete_space_result object
// rather than the per-node array used by wiki move.

View File

@@ -66,6 +66,13 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
},
wantErr: "--task-id is required",
},
{
name: "wiki move to Drive missing task id",
flags: map[string]string{
"scenario": "wiki_move_to_drive",
},
wantErr: "--task-id is required",
},
}
for _, tt := range tests {
@@ -426,13 +433,174 @@ func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) {
}
}
func TestDriveTaskResultDryRunWikiMoveToDriveIncludesTaskTypeParam(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +task_result"}
cmd.Flags().String("scenario", "", "")
cmd.Flags().String("ticket", "", "")
cmd.Flags().String("task-id", "", "")
cmd.Flags().String("file-token", "", "")
if err := cmd.Flags().Set("scenario", "wiki_move_to_drive"); err != nil {
t.Fatalf("set --scenario: %v", err)
}
if err := cmd.Flags().Set("task-id", "raw-task-signature"); err != nil {
t.Fatalf("set --task-id: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := DriveTaskResult.DryRun(context.Background(), runtime)
if dry == nil {
t.Fatal("DryRun returned nil")
}
data, err := json.Marshal(dry)
if err != nil {
t.Fatalf("marshal dry run: %v", err)
}
var got struct {
API []struct {
Params map[string]interface{} `json:"params"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 || got.API[0].Params["task_type"] != "move_wiki_to_docs" {
t.Fatalf("wiki move-to-drive dry run = %#v", got.API)
}
}
func TestDriveTaskResultWikiMoveToDriveStatuses(t *testing.T) {
tests := []struct {
name string
status int
statusMsg string
wantReady bool
wantFailed bool
}{
{name: "success", status: 0, statusMsg: "success", wantReady: true},
{name: "processing fallback label", status: 1, wantReady: false},
{name: "failure", status: -1, statusMsg: "failure", wantFailed: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, driveTestConfig())
registry.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
// The external handler may omit task.task_id, so the
// result must retain the signed request ID.
"move_wiki_to_docs_result": map[string]interface{}{
"status": tt.status,
"status_msg": tt.statusMsg,
"obj_token": "docxABC",
"obj_type": "docx",
"url": "https://example.feishu.cn/docx/docxABC",
},
},
},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "wiki_move_to_drive",
"--task-id", "raw-task-signature",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunDrive() error = %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if data["scenario"] != "wiki_move_to_drive" || data["task_id"] != "raw-task-signature" {
t.Fatalf("unexpected envelope = %#v", data)
}
if data["ready"] != tt.wantReady || data["failed"] != tt.wantFailed {
t.Fatalf("readiness fields = %#v", data)
}
if tt.statusMsg == "" && data["status_msg"] != "processing" {
t.Fatalf("status_msg = %#v, want processing fallback", data["status_msg"])
}
if data["obj_token"] != "docxABC" || data["obj_type"] != "docx" || data["url"] == "" {
t.Fatalf("result fields = %#v", data)
}
})
}
}
func TestDriveTaskResultWikiMoveToDriveRejectsMissingResult(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, driveTestConfig())
registry.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task": map[string]interface{}{}},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "wiki_move_to_drive",
"--task-id", "raw-task-signature",
"--as", "user",
}, factory, stdout)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
}
}
func TestDriveTaskResultWikiMoveToDriveRejectsMalformedStatus(t *testing.T) {
for name, rawStatus := range map[string]interface{}{
"null": nil,
"string": "processing",
"fractional": 0.5,
"unknown value": 2,
} {
t.Run(name, func(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, driveTestConfig())
registry.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
"move_wiki_to_docs_result": map[string]interface{}{"status": rawStatus},
},
},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "wiki_move_to_drive",
"--task-id", "raw-task-signature",
"--as", "user",
}, factory, stdout)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
}
})
}
}
func TestValidateDriveTaskResultScopesWikiScenariosRequireWikiScope(t *testing.T) {
t.Parallel()
// wiki_move, wiki_delete_space and wiki_delete_node all read wiki task
// status, so all must require wiki:space:read. A single table keeps this
// invariant explicit without duplicating near-identical test functions.
for _, scenario := range []string{"wiki_move", "wiki_delete_space", "wiki_delete_node"} {
// Every Wiki scenario reads Wiki task status, so all must require
// wiki:space:read. A single table keeps this invariant explicit without
// duplicating near-identical test functions.
for _, scenario := range []string{"wiki_move", "wiki_move_to_drive", "wiki_delete_space", "wiki_delete_node"} {
t.Run(scenario+"/rejects missing scope", func(t *testing.T) {
t.Parallel()
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly")

View File

@@ -9,6 +9,7 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
WikiMove,
WikiMoveToDrive,
WikiNodeCreate,
WikiDeleteSpace,
WikiSpaceList,

View File

@@ -0,0 +1,415 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
// These are fixed backend wire values. Keep them unchanged even though the
// shortcut and its continuation scenario use the user-facing Drive name.
wikiMoveToDriveTaskType = "move_wiki_to_docs"
wikiMoveToDriveResult = "move_wiki_to_docs_result"
wikiMoveToDriveStatusSuccess = 0
wikiMoveToDriveStatusProcessing = 1
wikiMoveToDriveStatusFailure = -1
)
var (
wikiMoveToDrivePollAttempts = 30
wikiMoveToDrivePollInterval = 2 * time.Second
)
// WikiMoveToDrive moves a Wiki node out of its knowledge space and into a
// Drive folder. The API always creates an async task, so the shortcut polls the
// Wiki task endpoint and returns a resumable command when the bounded window
// expires.
var WikiMoveToDrive = common.Shortcut{
Service: "wiki",
Command: "+move-to-drive",
Description: "Move a wiki node to a Drive folder, polling the async task until it finishes",
Risk: "write",
// The move endpoint's wiki:wiki / wiki:node:move /
// space:document:move list is an OR-set, while Shortcut.Scopes is an
// ALL-required preflight. Use the registry's highest-priority candidate
// plus the read scope required by the task-status endpoint.
Scopes: []string{"space:document:move", "wiki:space:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "node-token", Desc: "wiki node_token to move out of the knowledge space", Required: true},
{Name: "folder-token", Desc: "target Drive folder token; omit to move to the calling identity's personal-space root"},
},
Tips: []string{
"The source must be a wiki node_token, not the backing document's obj_token; use wiki +node-get when unsure.",
"Omit --folder-token to move the document to the calling identity's personal-space root.",
"Moving out of Wiki removes the node from the Wiki tree and replaces inherited Wiki permissions with the target Drive folder's permission model.",
"The move is asynchronous; if the bounded poll times out, continue with the next_command returned in the output.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateWikiMoveToDriveSpec(readWikiMoveToDriveSpec(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return buildWikiMoveToDriveDryRun(readWikiMoveToDriveSpec(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := readWikiMoveToDriveSpec(runtime)
out, err := runWikiMoveToDrive(ctx, wikiMoveToDriveAPI{runtime: runtime}, runtime, spec)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
type wikiMoveToDriveSpec struct {
NodeToken string
FolderToken string
}
func (spec wikiMoveToDriveSpec) RequestBody() map[string]interface{} {
body := map[string]interface{}{}
if spec.FolderToken != "" {
body["folder_token"] = spec.FolderToken
}
return body
}
type wikiMoveToDriveTaskStatus struct {
TaskID string
Status int
StatusMsg string
ObjToken string
ObjType string
URL string
}
func (s wikiMoveToDriveTaskStatus) Ready() bool {
return s.Status == wikiMoveToDriveStatusSuccess
}
func (s wikiMoveToDriveTaskStatus) Failed() bool {
return s.Status < wikiMoveToDriveStatusSuccess
}
func (s wikiMoveToDriveTaskStatus) StatusLabel() string {
if label := strings.TrimSpace(s.StatusMsg); label != "" {
return label
}
switch {
case s.Ready():
return "success"
case s.Failed():
return "failure"
default:
return "processing"
}
}
type wikiMoveToDriveClient interface {
MoveWikiToDrive(ctx context.Context, spec wikiMoveToDriveSpec) (string, error)
GetMoveWikiToDriveTask(ctx context.Context, taskID string) (wikiMoveToDriveTaskStatus, error)
}
type wikiMoveToDriveAPI struct {
runtime *common.RuntimeContext
}
func (api wikiMoveToDriveAPI) MoveWikiToDrive(ctx context.Context, spec wikiMoveToDriveSpec) (string, error) {
data, err := api.runtime.CallAPITyped(
"POST",
fmt.Sprintf(
"/open-apis/wiki/v2/nodes/%s/move_wiki_to_docs",
validate.EncodePathSegment(spec.NodeToken),
),
nil,
spec.RequestBody(),
)
if err != nil {
return "", err
}
taskID := common.GetString(data, "task_id")
if taskID == "" {
return "", errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki move-to-drive response missing task_id")
}
return taskID, nil
}
func (api wikiMoveToDriveAPI) GetMoveWikiToDriveTask(ctx context.Context, taskID string) (wikiMoveToDriveTaskStatus, error) {
data, err := api.runtime.CallAPITyped(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": wikiMoveToDriveTaskType},
nil,
)
if err != nil {
return wikiMoveToDriveTaskStatus{}, err
}
return parseWikiMoveToDriveTaskStatus(taskID, common.GetMap(data, "task"))
}
func readWikiMoveToDriveSpec(runtime *common.RuntimeContext) wikiMoveToDriveSpec {
return wikiMoveToDriveSpec{
NodeToken: strings.TrimSpace(runtime.Str("node-token")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
}
}
func validateWikiMoveToDriveSpec(spec wikiMoveToDriveSpec) error {
if spec.NodeToken == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--node-token is required").WithParam("--node-token")
}
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
return err
}
return validateOptionalResourceName(spec.FolderToken, "--folder-token")
}
func buildWikiMoveToDriveDryRun(spec wikiMoveToDriveSpec) *common.DryRunAPI {
dry := common.NewDryRunAPI().Desc(
"2-step orchestration: move wiki node to Drive -> poll wiki move-to-drive task result",
)
dry.POST(fmt.Sprintf(
"/open-apis/wiki/v2/nodes/%s/move_wiki_to_docs",
validate.EncodePathSegment(spec.NodeToken),
)).
Desc("[1] Move wiki node to Drive").
Body(spec.RequestBody())
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[2] Poll wiki move-to-drive task result").
Set("task_id", "<task_id>").
Params(map[string]interface{}{"task_type": wikiMoveToDriveTaskType})
return dry
}
func runWikiMoveToDrive(
ctx context.Context,
client wikiMoveToDriveClient,
runtime *common.RuntimeContext,
spec wikiMoveToDriveSpec,
) (map[string]interface{}, error) {
folderLabel := "personal-space root"
if spec.FolderToken != "" {
folderLabel = common.MaskToken(spec.FolderToken)
}
fmt.Fprintf(
runtime.IO().ErrOut,
"Moving wiki node %s to Drive folder %s...\n",
common.MaskToken(spec.NodeToken),
folderLabel,
)
taskID, err := client.MoveWikiToDrive(ctx, spec)
if err != nil {
return nil, err
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move-to-drive is async, polling task %s...\n", taskID)
status, ready, err := pollWikiMoveToDriveTask(ctx, client, runtime, taskID)
if err != nil {
return nil, err
}
out := map[string]interface{}{
"node_token": spec.NodeToken,
"folder_token": spec.FolderToken,
"task_id": taskID,
"ready": ready,
"failed": status.Failed(),
"status": status.Status,
"status_msg": status.StatusLabel(),
"obj_token": status.ObjToken,
"obj_type": status.ObjType,
"url": status.URL,
}
if !ready {
nextCommand := wikiMoveToDriveTaskResultCommand(taskID, runtime.As(), wikiMoveToDriveProfileName(runtime))
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move-to-drive task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
return out, nil
}
func pollWikiMoveToDriveTask(
ctx context.Context,
client wikiMoveToDriveClient,
runtime *common.RuntimeContext,
taskID string,
) (wikiMoveToDriveTaskStatus, bool, error) {
lastStatus := wikiMoveToDriveTaskStatus{
TaskID: taskID,
Status: wikiMoveToDriveStatusProcessing,
}
var lastErr error
hadSuccessfulPoll := false
for attempt := 1; attempt <= wikiMoveToDrivePollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return lastStatus, false, wrapWikiMoveToDrivePollContextError(
ctx.Err(), taskID, runtime.As(), wikiMoveToDriveProfileName(runtime),
)
case <-time.After(wikiMoveToDrivePollInterval):
}
}
status, err := client.GetMoveWikiToDriveTask(ctx, taskID)
if err != nil {
if contextErr := ctx.Err(); contextErr != nil {
return lastStatus, false, wrapWikiMoveToDrivePollContextError(
contextErr, taskID, runtime.As(), wikiMoveToDriveProfileName(runtime),
)
}
lastErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move-to-drive status attempt %d/%d failed: %v\n", attempt, wikiMoveToDrivePollAttempts, err)
continue
}
lastStatus = status
hadSuccessfulPoll = true
if status.Ready() {
fmt.Fprintln(runtime.IO().ErrOut, "Wiki move-to-drive task completed successfully.")
return status, true, nil
}
if status.Failed() {
return status, false, errs.NewAPIError(
errs.SubtypeServerError,
"wiki move-to-drive task %s failed: %s",
taskID,
status.StatusLabel(),
)
}
fmt.Fprintf(
runtime.IO().ErrOut,
"Wiki move-to-drive status %d/%d: %s\n",
attempt,
wikiMoveToDrivePollAttempts,
status.StatusLabel(),
)
}
if err := ctx.Err(); err != nil {
return lastStatus, false, wrapWikiMoveToDrivePollContextError(
err, taskID, runtime.As(), wikiMoveToDriveProfileName(runtime),
)
}
if !hadSuccessfulPoll && lastErr != nil {
hint := fmt.Sprintf(
"the wiki move-to-drive task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
taskID,
wikiMoveToDriveTaskResultCommand(taskID, runtime.As(), wikiMoveToDriveProfileName(runtime)),
)
if _, ok := errs.ProblemOf(lastErr); ok {
return lastStatus, false, appendWikiProblemHint(lastErr, hint)
}
return lastStatus, false, errs.NewInternalError(errs.SubtypeUnknown, "%s", lastErr.Error()).
WithHint("%s", hint).
WithCause(lastErr)
}
return lastStatus, false, nil
}
func parseWikiMoveToDriveTaskStatus(taskID string, task map[string]interface{}) (wikiMoveToDriveTaskStatus, error) {
if task == nil {
return wikiMoveToDriveTaskStatus{}, errs.NewInternalError(errs.SubtypeInvalidResponse, "wiki task response missing task")
}
result := common.GetMap(task, wikiMoveToDriveResult)
if result == nil {
return wikiMoveToDriveTaskStatus{}, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"wiki task response missing %s",
wikiMoveToDriveResult,
)
}
statusCode, ok := common.GetFloatOK(result, "status")
if !ok {
return wikiMoveToDriveTaskStatus{}, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"wiki task response has missing or non-numeric %s.status",
wikiMoveToDriveResult,
)
}
if statusCode != wikiMoveToDriveStatusFailure &&
statusCode != wikiMoveToDriveStatusSuccess &&
statusCode != wikiMoveToDriveStatusProcessing {
return wikiMoveToDriveTaskStatus{}, errs.NewInternalError(
errs.SubtypeInvalidResponse,
"wiki task response has unsupported %s.status: %v",
wikiMoveToDriveResult,
statusCode,
)
}
status := wikiMoveToDriveTaskStatus{
TaskID: common.GetString(task, "task_id"),
Status: int(statusCode),
StatusMsg: common.GetString(result, "status_msg"),
ObjToken: common.GetString(result, "obj_token"),
ObjType: common.GetString(result, "obj_type"),
URL: common.GetString(result, "url"),
}
if status.TaskID == "" {
status.TaskID = taskID
}
return status, nil
}
// Preserve the originating identity and profile so a resumed status lookup
// uses the same credential context that created the async task.
func wikiMoveToDriveTaskResultCommand(taskID string, identity core.Identity, profileName string) string {
asFlag := string(identity)
if asFlag == "" {
asFlag = "user"
}
profileFlag := ""
if profileName != "" {
profileFlag = fmt.Sprintf(" --profile %s", profileName)
}
return fmt.Sprintf(
"lark-cli%s drive +task_result --scenario wiki_move_to_drive --task-id %s --as %s",
profileFlag,
taskID,
asFlag,
)
}
func wikiMoveToDriveProfileName(runtime *common.RuntimeContext) string {
if runtime == nil || runtime.Config == nil {
return ""
}
return runtime.Config.ProfileName
}
func wrapWikiMoveToDrivePollContextError(err error, taskID string, identity core.Identity, profileName string) error {
if err == nil {
return nil
}
subtype := errs.SubtypeNetworkTransport
message := "wiki move-to-drive task polling cancelled: %s"
if errors.Is(err, context.DeadlineExceeded) {
subtype = errs.SubtypeNetworkTimeout
message = "wiki move-to-drive task polling deadline exceeded: %s"
}
return errs.NewNetworkError(subtype, message, err).
WithHint("the task may still be running; retry status lookup with: %s", wikiMoveToDriveTaskResultCommand(taskID, identity, profileName)).
WithCause(err)
}

View File

@@ -0,0 +1,481 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"bytes"
"context"
"errors"
"net/http"
"reflect"
"strings"
"sync"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
type fakeWikiMoveToDriveClient struct {
moveTaskID string
moveErr error
taskStatus []wikiMoveToDriveTaskStatus
taskErrs []error
taskHooks []func()
moveSpecs []wikiMoveToDriveSpec
taskCalls []string
}
func (fake *fakeWikiMoveToDriveClient) MoveWikiToDrive(ctx context.Context, spec wikiMoveToDriveSpec) (string, error) {
fake.moveSpecs = append(fake.moveSpecs, spec)
if fake.moveErr != nil {
return "", fake.moveErr
}
return fake.moveTaskID, nil
}
func (fake *fakeWikiMoveToDriveClient) GetMoveWikiToDriveTask(ctx context.Context, taskID string) (wikiMoveToDriveTaskStatus, error) {
idx := len(fake.taskCalls)
fake.taskCalls = append(fake.taskCalls, taskID)
if idx < len(fake.taskHooks) && fake.taskHooks[idx] != nil {
fake.taskHooks[idx]()
}
if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil {
return wikiMoveToDriveTaskStatus{TaskID: taskID, Status: wikiMoveToDriveStatusProcessing}, fake.taskErrs[idx]
}
if idx < len(fake.taskStatus) {
status := fake.taskStatus[idx]
if status.TaskID == "" {
status.TaskID = taskID
}
return status, nil
}
return wikiMoveToDriveTaskStatus{TaskID: taskID, Status: wikiMoveToDriveStatusProcessing}, nil
}
var wikiMoveToDrivePollMu sync.Mutex
func withSingleWikiMoveToDrivePoll(t *testing.T) {
withWikiMoveToDrivePoll(t, 1)
}
func withWikiMoveToDrivePoll(t *testing.T, attempts int) {
t.Helper()
wikiMoveToDrivePollMu.Lock()
previousAttempts, previousInterval := wikiMoveToDrivePollAttempts, wikiMoveToDrivePollInterval
wikiMoveToDrivePollAttempts, wikiMoveToDrivePollInterval = attempts, 0
t.Cleanup(func() {
wikiMoveToDrivePollAttempts, wikiMoveToDrivePollInterval = previousAttempts, previousInterval
wikiMoveToDrivePollMu.Unlock()
})
}
func newWikiMoveToDriveRuntime(t *testing.T, identity core.Identity) (*common.RuntimeContext, *bytes.Buffer) {
t.Helper()
cfg := wikiTestConfig()
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
runtime := common.TestNewRuntimeContextWithIdentity(nil, cfg, identity)
runtime.Factory = factory
return runtime, stderr
}
func TestWikiMoveToDriveDeclaredContract(t *testing.T) {
t.Parallel()
wantScopes := []string{"space:document:move", "wiki:space:read"}
if !reflect.DeepEqual(WikiMoveToDrive.Scopes, wantScopes) {
t.Fatalf("WikiMoveToDrive.Scopes = %v, want %v", WikiMoveToDrive.Scopes, wantScopes)
}
if WikiMoveToDrive.Risk != "write" {
t.Fatalf("WikiMoveToDrive.Risk = %q, want write", WikiMoveToDrive.Risk)
}
}
func TestValidateWikiMoveToDriveSpec(t *testing.T) {
t.Parallel()
t.Run("requires node token", func(t *testing.T) {
err := validateWikiMoveToDriveSpec(wikiMoveToDriveSpec{})
requireWikiValidationParams(t, err, "--node-token")
})
t.Run("rejects unsafe folder token", func(t *testing.T) {
err := validateWikiMoveToDriveSpec(wikiMoveToDriveSpec{
NodeToken: "wikcnABC",
FolderToken: "../folder",
})
requireWikiValidationParams(t, err, "--folder-token")
cause := errors.Unwrap(err)
if cause == nil || !errors.Is(err, cause) {
t.Fatal("validation error must preserve its path-validation cause")
}
})
t.Run("accepts optional folder", func(t *testing.T) {
err := validateWikiMoveToDriveSpec(wikiMoveToDriveSpec{NodeToken: "wikcnABC"})
if err != nil {
t.Fatalf("validateWikiMoveToDriveSpec() error = %v", err)
}
})
}
func TestWikiMoveToDriveRequestBodyOmitsEmptyFolder(t *testing.T) {
t.Parallel()
withoutFolder := (wikiMoveToDriveSpec{NodeToken: "wikcnABC"}).RequestBody()
if _, exists := withoutFolder["folder_token"]; exists {
t.Fatalf("empty folder_token must be omitted, got %#v", withoutFolder)
}
withFolder := (wikiMoveToDriveSpec{NodeToken: "wikcnABC", FolderToken: "fldABC"}).RequestBody()
if withFolder["folder_token"] != "fldABC" {
t.Fatalf("RequestBody() = %#v, want folder_token=fldABC", withFolder)
}
}
func TestBuildWikiMoveToDriveDryRun(t *testing.T) {
t.Parallel()
steps := decodeDryRunAPIs(t, buildWikiMoveToDriveDryRun(wikiMoveToDriveSpec{
NodeToken: "wikcnABC",
FolderToken: "fldABC",
}))
if len(steps) != 2 {
t.Fatalf("len(api) = %d, want 2", len(steps))
}
if steps[0].Method != "POST" || steps[0].URL != "/open-apis/wiki/v2/nodes/wikcnABC/move_wiki_to_docs" {
t.Fatalf("POST step = %#v", steps[0])
}
if steps[0].Body["folder_token"] != "fldABC" {
t.Fatalf("POST body = %#v", steps[0].Body)
}
if steps[1].Method != "GET" || steps[1].URL != "/open-apis/wiki/v2/tasks/%3Ctask_id%3E" {
t.Fatalf("GET step = %#v", steps[1])
}
if steps[1].Params["task_type"] != wikiMoveToDriveTaskType {
t.Fatalf("task params = %#v", steps[1].Params)
}
}
func TestParseWikiMoveToDriveTaskStatus(t *testing.T) {
t.Parallel()
t.Run("success with task id fallback and result fields", func(t *testing.T) {
status, err := parseWikiMoveToDriveTaskStatus("signed-task-id", map[string]interface{}{
"move_wiki_to_docs_result": map[string]interface{}{
"status": float64(0),
"status_msg": "success",
"obj_token": "docxABC",
"obj_type": "docx",
"url": "https://example.feishu.cn/docx/docxABC",
},
})
if err != nil {
t.Fatalf("parseWikiMoveToDriveTaskStatus() error = %v", err)
}
if status.TaskID != "signed-task-id" || !status.Ready() || status.Failed() {
t.Fatalf("status = %+v", status)
}
if status.ObjToken != "docxABC" || status.ObjType != "docx" || status.URL == "" {
t.Fatalf("result fields = %+v", status)
}
})
t.Run("rejects missing dedicated result", func(t *testing.T) {
_, err := parseWikiMoveToDriveTaskStatus("task", map[string]interface{}{})
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
}
})
t.Run("rejects missing status", func(t *testing.T) {
_, err := parseWikiMoveToDriveTaskStatus("task", map[string]interface{}{
"move_wiki_to_docs_result": map[string]interface{}{},
})
problem, ok := errs.ProblemOf(err)
if !ok || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("error = %T %v, want invalid_response", err, err)
}
})
for name, rawStatus := range map[string]interface{}{
"null": nil,
"string": "processing",
"fractional": 0.5,
"unknown value": 2,
} {
t.Run("rejects "+name+" status", func(t *testing.T) {
_, err := parseWikiMoveToDriveTaskStatus("task", map[string]interface{}{
"move_wiki_to_docs_result": map[string]interface{}{"status": rawStatus},
})
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
}
})
}
}
func TestRunWikiMoveToDriveSuccess(t *testing.T) {
withSingleWikiMoveToDrivePoll(t)
runtime, stderr := newWikiMoveToDriveRuntime(t, core.AsUser)
client := &fakeWikiMoveToDriveClient{
moveTaskID: "raw-task-signature",
taskStatus: []wikiMoveToDriveTaskStatus{{
Status: wikiMoveToDriveStatusSuccess,
StatusMsg: "success",
ObjToken: "docxABC",
ObjType: "docx",
URL: "https://example.feishu.cn/docx/docxABC",
}},
}
out, err := runWikiMoveToDrive(context.Background(), client, runtime, wikiMoveToDriveSpec{
NodeToken: "wikcnABC",
FolderToken: "fldABC",
})
if err != nil {
t.Fatalf("runWikiMoveToDrive() error = %v", err)
}
if out["task_id"] != "raw-task-signature" || out["ready"] != true || out["failed"] != false {
t.Fatalf("output = %#v", out)
}
if out["obj_token"] != "docxABC" || out["obj_type"] != "docx" || out["url"] == "" {
t.Fatalf("output result fields = %#v", out)
}
if len(client.moveSpecs) != 1 || client.moveSpecs[0].FolderToken != "fldABC" {
t.Fatalf("move specs = %#v", client.moveSpecs)
}
if !strings.Contains(stderr.String(), "completed successfully") {
t.Fatalf("stderr = %q", stderr.String())
}
}
func TestRunWikiMoveToDriveTimeoutReturnsResumeCommand(t *testing.T) {
withSingleWikiMoveToDrivePoll(t)
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsBot)
runtime.Config.ProfileName = "secondary"
client := &fakeWikiMoveToDriveClient{
moveTaskID: "raw-task-signature",
taskStatus: []wikiMoveToDriveTaskStatus{{Status: wikiMoveToDriveStatusProcessing}},
}
out, err := runWikiMoveToDrive(context.Background(), client, runtime, wikiMoveToDriveSpec{NodeToken: "wikcnABC"})
if err != nil {
t.Fatalf("runWikiMoveToDrive() error = %v", err)
}
if out["ready"] != false || out["failed"] != false || out["timed_out"] != true {
t.Fatalf("timeout output = %#v", out)
}
nextCommand, _ := out["next_command"].(string)
if !strings.HasPrefix(nextCommand, "lark-cli --profile secondary drive +task_result") ||
!strings.Contains(nextCommand, "--scenario wiki_move_to_drive") ||
!strings.Contains(nextCommand, "--as bot") {
t.Fatalf("next_command = %q", nextCommand)
}
}
func TestPollWikiMoveToDriveContinuesFromProcessingToSuccess(t *testing.T) {
withWikiMoveToDrivePoll(t, 2)
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
client := &fakeWikiMoveToDriveClient{
taskStatus: []wikiMoveToDriveTaskStatus{
{Status: wikiMoveToDriveStatusProcessing},
{Status: wikiMoveToDriveStatusSuccess, ObjToken: "docxABC"},
},
}
status, ready, err := pollWikiMoveToDriveTask(context.Background(), client, runtime, "signed-task-id")
if err != nil || !ready || !status.Ready() || status.ObjToken != "docxABC" {
t.Fatalf("status=%+v ready=%t err=%v", status, ready, err)
}
if len(client.taskCalls) != 2 {
t.Fatalf("task calls = %v, want two attempts", client.taskCalls)
}
}
func TestPollWikiMoveToDriveRecoversFromTransientError(t *testing.T) {
withWikiMoveToDrivePoll(t, 2)
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
requestTimeout := errs.NewNetworkError(errs.SubtypeNetworkTimeout, "temporary request timeout").
WithCause(context.DeadlineExceeded)
client := &fakeWikiMoveToDriveClient{
taskErrs: []error{requestTimeout},
taskStatus: []wikiMoveToDriveTaskStatus{
{},
{Status: wikiMoveToDriveStatusSuccess, ObjToken: "docxABC"},
},
}
status, ready, err := pollWikiMoveToDriveTask(context.Background(), client, runtime, "signed-task-id")
if err != nil || !ready || !status.Ready() || status.ObjToken != "docxABC" {
t.Fatalf("status=%+v ready=%t err=%v", status, ready, err)
}
if len(client.taskCalls) != 2 {
t.Fatalf("task calls = %v, want two attempts", client.taskCalls)
}
}
func TestPollWikiMoveToDriveDoesNotSwallowFinalCancellation(t *testing.T) {
withWikiMoveToDrivePoll(t, 2)
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
ctx, cancel := context.WithCancel(context.Background())
client := &fakeWikiMoveToDriveClient{
taskStatus: []wikiMoveToDriveTaskStatus{{Status: wikiMoveToDriveStatusProcessing}},
taskErrs: []error{nil, context.Canceled},
taskHooks: []func(){nil, cancel},
}
_, ready, err := pollWikiMoveToDriveTask(ctx, client, runtime, "signed-task-id")
if ready || !errors.Is(err, context.Canceled) {
t.Fatalf("ready=%t err=%T %v, want preserved context cancellation", ready, err, err)
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTransport {
t.Fatalf("error = %T %v, want network/transport", err, err)
}
}
func TestRunWikiMoveToDriveFailureIsTypedAPIError(t *testing.T) {
withSingleWikiMoveToDrivePoll(t)
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
client := &fakeWikiMoveToDriveClient{
moveTaskID: "raw-task-signature",
taskStatus: []wikiMoveToDriveTaskStatus{{Status: wikiMoveToDriveStatusFailure, StatusMsg: "failure"}},
}
_, err := runWikiMoveToDrive(context.Background(), client, runtime, wikiMoveToDriveSpec{NodeToken: "wikcnABC"})
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryAPI || problem.Subtype != errs.SubtypeServerError {
t.Fatalf("error = %T %v, want api/server_error", err, err)
}
}
func TestPollWikiMoveToDrivePreservesTypedPollError(t *testing.T) {
withSingleWikiMoveToDrivePoll(t)
runtime, _ := newWikiMoveToDriveRuntime(t, core.AsUser)
cause := errors.New("connection reset")
upstream := errs.NewNetworkError(errs.SubtypeNetworkTransport, "poll failed").
WithCode(503).
WithHint("retry upstream").
WithCause(cause)
client := &fakeWikiMoveToDriveClient{taskErrs: []error{upstream}}
_, ready, err := pollWikiMoveToDriveTask(context.Background(), client, runtime, "raw-task-signature")
if ready || err != upstream {
t.Fatalf("ready=%t err=%T %v, want original typed error", ready, err, err)
}
if !errors.Is(err, cause) {
t.Fatal("typed poll error must preserve its cause")
}
problem, _ := errs.ProblemOf(err)
if problem.Code != 503 || !strings.Contains(problem.Hint, "retry upstream") || !strings.Contains(problem.Hint, "wiki_move_to_drive") {
t.Fatalf("problem = %+v", problem)
}
}
func TestWrapWikiMoveToDrivePollContextError(t *testing.T) {
t.Parallel()
err := wrapWikiMoveToDrivePollContextError(context.DeadlineExceeded, "task-id", core.AsUser, "secondary")
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatal("wrapped deadline must preserve context.DeadlineExceeded")
}
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryNetwork || problem.Subtype != errs.SubtypeNetworkTimeout {
t.Fatalf("error = %T %v, want network/timeout", err, err)
}
if !strings.Contains(problem.Hint, "wiki_move_to_drive") || !strings.Contains(problem.Hint, "--profile secondary") {
t.Fatalf("hint = %q", problem.Hint)
}
}
func TestWikiMoveToDriveExecuteCallsPostAndTaskEndpoint(t *testing.T) {
withSingleWikiMoveToDrivePoll(t)
factory, stdout, _, registry := cmdutil.TestFactory(t, wikiTestConfig())
moveStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/nodes/wikcnABC/move_wiki_to_docs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "raw-task-signature"},
},
}
registry.Register(moveStub)
var taskQuery string
taskStub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/raw-task-signature",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
// The external handler currently omits task.task_id for this
// task type, so the CLI must preserve the signed request ID.
"move_wiki_to_docs_result": map[string]interface{}{
"status": 0,
"status_msg": "success",
"obj_token": "docxABC",
"obj_type": "docx",
"url": "https://example.feishu.cn/docx/docxABC",
},
},
},
},
}
taskStub.OnMatch = func(req *http.Request) { taskQuery = req.URL.RawQuery }
registry.Register(taskStub)
err := mountAndRunWiki(t, WikiMoveToDrive, []string{
"+move-to-drive",
"--node-token", "wikcnABC",
"--folder-token", "fldABC",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
body := decodeWikiCapturedJSONBody(t, moveStub)
if body["folder_token"] != "fldABC" {
t.Fatalf("captured POST body = %#v", body)
}
if !strings.Contains(taskQuery, "task_type=move_wiki_to_docs") {
t.Fatalf("task query = %q", taskQuery)
}
data := decodeWikiEnvelope(t, stdout)
if data["task_id"] != "raw-task-signature" || data["ready"] != true || data["obj_token"] != "docxABC" {
t.Fatalf("output = %#v", data)
}
}
func TestWikiMoveToDriveExecuteRejectsMissingTaskID(t *testing.T) {
factory, stdout, _, registry := cmdutil.TestFactory(t, wikiTestConfig())
registry.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/nodes/wikcnABC/move_wiki_to_docs",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{},
},
})
err := mountAndRunWiki(t, WikiMoveToDrive, []string{
"+move-to-drive",
"--node-token", "wikcnABC",
"--as", "user",
}, factory, stdout)
problem, ok := errs.ProblemOf(err)
if !ok || problem.Category != errs.CategoryInternal || problem.Subtype != errs.SubtypeInvalidResponse {
t.Fatalf("error = %T %v, want internal/invalid_response", err, err)
}
}

View File

@@ -18,6 +18,7 @@ import (
"testing"
extcred "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/sidecar"
@@ -585,11 +586,15 @@ func TestProxyHandler_StripsClientSuppliedAuthHeaders(t *testing.T) {
}
func TestBuildAllowedHosts(t *testing.T) {
feishu := struct{ Open, Accounts, MCP string }{
"https://open.feishu.cn", "https://accounts.feishu.cn", "https://mcp.feishu.cn",
feishu := core.Endpoints{
Open: "https://open.feishu.cn",
Accounts: "https://accounts.feishu.cn",
MCP: "https://mcp.feishu.cn",
}
lark := struct{ Open, Accounts, MCP string }{
"https://open.larksuite.com", "https://accounts.larksuite.com", "https://mcp.larksuite.com",
lark := core.Endpoints{
Open: "https://open.larksuite.com",
Accounts: "https://accounts.larksuite.com",
MCP: "https://mcp.larksuite.com",
}
hosts := buildAllowedHosts(feishu, lark)
// feishu hosts

View File

@@ -3,6 +3,7 @@
## 快速决策
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:切到 `lark-wiki`,使用 `lark-cli wiki +move-to-drive`;不要把 Wiki token 直接交给 `drive +move`。执行前确认源节点与目标位置。
- 用户要把本地 `.xlsx` / `.csv` / `.base` 导入成 Base / 多维表格 / bitable第一步必须使用 `lark-cli drive +import --type bitable`
- 用户要把本地 `.md` / `.docx` / `.doc` / `.txt` / `.html` 导入成在线文档,使用 `lark-cli drive +import --type docx`
- 用户要把本地 `.xlsx` / `.xls` / `.csv` 导入成电子表格,使用 `lark-cli drive +import --type sheet`

View File

@@ -7,6 +7,7 @@
## 快速决策
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow-knowledge-organize.md`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md),该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:使用 `wiki +move-to-drive`,不要使用 `wiki +move``drive +move`。执行前确认源节点与目标位置。
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`
- 用户要**删除**知识空间(`wiki +delete-space`)但只给了名称或 URL**不能**把名称 / URL 原样传给 `--space-id`,必须先解析出真实 `space_id`。解析方式:
- URL`.../wiki/<token>``lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}' --format json`,读 `data.node.space_id`
@@ -37,4 +38,4 @@
- `我的文档库` / `My Document Library` / `我的知识库` / `个人知识库` / `my_library` 都应视为 **Wiki personal library**,不是 Drive 根目录
- 处理这类目标时,先解析 `my_library` 对应的真实 `space_id`,再执行 `wiki +move``wiki +node-create` 或其他 Wiki 写操作
- 不要因为缺少显式 `space_id` 就退化成 `drive +move`
- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`才进入 Drive 域处理
- 如果用户明确说的是 Drive 文件夹、云空间根目录、`我的空间`再按源对象分流:源对象是 Wiki 节点时用 `wiki +move-to-drive`,源对象已在 Drive 时用 `drive +move`

View File

@@ -1,7 +1,7 @@
---
name: lark-apps
version: 1.0.0
description: "妙搭Spark/Miaoda应用开发与托管应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理、自动化触发器(定时/记录变更/Webhook/飞书审批)。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda应用运行时域名形如 *.aiforce.cloud、应用数据库、应用文件存储、开放 API Key、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量、给妙搭应用配自动化任务/定时触发/审批通过后自动触发时使用。不负责普通云盘文件上传lark-drive、飞书文档编辑lark-doc、原生幻灯片创建lark-slides。"
description: "妙搭Spark/Miaoda应用开发与托管应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、AI相关能力和飞书平台能力或者其他外部能力集成、日志/Trace/监控指标/PV/UV 查询、环境变量管理、应用角色与成员管理、自动化触发器(定时/记录变更/Webhook/飞书审批)。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站·部署到妙搭,或提到妙搭/Spark/Miaoda应用运行时域名形如 *.aiforce.cloud、应用数据库、应用文件存储、开放 API Key、可见范围、应用角色/角色成员、线上日志、接口请求量、错误量、延迟、访问量、环境变量、给妙搭应用配自动化任务/定时触发/审批通过后自动触发时使用。不负责普通云盘文件上传lark-drive、飞书文档编辑lark-doc、原生幻灯片创建lark-slides。"
metadata:
requires:
bins: ["lark-cli"]
@@ -12,15 +12,15 @@ metadata:
妙搭应用属于用户资产。默认用 `--as user`认证、scope、exit-10、高风险确认、`_notice` 等通用处理只读 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),不要在本 skill 里复制。妙搭应用有三条开发路径:**本地全栈**(拉源码本地写)/ **HTML 托管**(发布静态产物)/ **云端会话**(妙搭 AI 生成)。
## 身份与一次性授权
## 身份与授权
妙搭应用是用户的个人资产,统一 `--as user`(见开头)。**首次操作前先一次性把本域 scope 全拿到**,避免每条命令首次跑都触发新一轮授权,或未授权直接打到 openapi 导致服务端报错
妙搭应用是用户的个人资产,统一 `--as user`(见开头)。已有用户身份可用时直接执行业务命令,**不要为了预防权限问题主动重新登录**,否则可能中断原任务并触发不必要的设备授权。仅当 CLI 明确返回未登录或缺少本域 scope 时,一次性执行
```bash
lark-cli auth login --domain apps
```
因缺权限失败(`error.subtype == "missing_scope"`)时的通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),同样按 `--domain apps` 授权。
因缺权限失败(`error.subtype == "missing_scope"`)时的通用处理见 [`../lark-shared/SKILL.md`](../lark-shared/SKILL.md),同样按 `--domain apps` 授权;授权成功后只恢复原业务操作,不扩展任务范围
## 意图路由
@@ -33,15 +33,16 @@ lark-cli auth login --domain apps
| 查单个应用详情(类型、名称、发布状态等) | `+get --app-id <app_id>` | [`lark-apps-get.md`](references/lark-apps-get.md) |
| 改应用名或描述 | `+update` | [`lark-apps-update.md`](references/lark-apps-update.md) |
| 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) |
| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id`+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git。**执行前必读** [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md),含端到端流程和领域规则 | [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) |
| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id`+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git。**执行前必读** [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md);修改源码还须遵守下方「平台资源与应用源码边界」 | [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) |
| 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) |
| 管理应用环境变量(查看/设置/删除) | `+env-list`, `+env-set`, `+env-delete` | [`lark-apps-env.md`](references/lark-apps-env.md) |
| 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-list`, `+analytics-list` | [`lark-apps-observability.md`](references/lark-apps-observability.md) |
| 看表 / 看结构 / 初始化多环境 / 导入导出数据 / 变更追溯 / 行级审计 / dev→online 发布 / 时间点恢复 / 查 DB 用量 | `+db-table-list``+db-table-get``+db-env-create``+db-data-export`/`+db-data-import``+db-changelog-list``+db-audit-status`/`+db-audit-enable`/`+db-audit-disable`/`+db-audit-list``+db-env-diff`/`+db-env-migrate``+db-recovery-diff`/`+db-recovery-apply``+db-quota-get` | [`lark-apps-db.md`](references/lark-apps-db.md) |
| 逐条执行 SQLSELECT / DML / DDL | `+db-execute` | [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md) |
| 逐条执行 SQLSELECT / DML / DDL;建表 / 改表 / 写 SQL 的平台规范 | `+db-execute` | [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md)(含「平台 SQL 规范」:审计列 / RLS / `user_profile` / 禁用 SQL / PG 陷阱) |
| 管理应用文件存储:上传/下载本地文件、列出/查看/删除已存文件、生成临时分享链接、查存储用量 | `+file-upload`/`+file-download`/`+file-list`/`+file-get`/`+file-sign`/`+file-delete`/`+file-quota-get` | [`lark-apps-file.md`](references/lark-apps-file.md) |
| **部署/上线全栈应用**"部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`轮询发布结果finished 给 online_url / failed 给 error_logs, `+release-list` | [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) |
| 设置或查看运行时可见范围 | `+access-scope-set`, `+access-scope-get` | 对应 access-scope reference |
| 管理 `app_...` 应用内角色、角色成员,或查询用户匹配角色 | `+role-list/get/create/update/delete`, `+role-member-list/add/remove`, `+role-match-list` | [`lark-apps-role.md`](references/lark-apps-role.md) |
| 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) |
| 管理妙搭应用开放 API Key创建/查看/启停/重置/删除凭证;密钥仅 create/reset 一次性返回) | `+openapi-key-list/get/create/update/enable/disable/delete/reset` | [`lark-apps-openapi-key.md`](references/lark-apps-openapi-key.md) |
| 管理妙搭应用自动化触发器(定时/记录变更/Webhook/飞书审批四类触发器的查询/创建/更新/启停Webhook URL·Token 一次性回显、不落盘) | `+automation-list/get/create/update/enable/disable` | [`lark-apps-automation.md`](references/lark-apps-automation.md) |
@@ -78,10 +79,15 @@ lark-cli auth login --domain apps
- 发布态链接来源html → `+html-publish``data.url`;全栈 → `+release-get` 轮询 `finished``online_url` / `failed``error_logs`
- **可见范围**发布态链接html 的 `data.url`、全栈的 `online_url`)默认仅**创建者可见**,发给他人对方会无权限打不开。当可分享链接交付给用户前,先告知当前仅本人可见,再询问是否用 `+access-scope-set``tenant`/`public`/`specific`)放开(可先 `+access-scope-get` 查当前范围)。
## 能力边界
## 平台资源与应用源码边界
- lark-cli **不支持**配置应用的权限(应用内 RBAC、成员角色、协作者权限`+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限
- 用户要配置权限时,引导其使用开发态链接前往云端开发(妙搭 web处理。自动化触发器请用 `+automation-*`(见「意图路由」)
- `apps +role-*` 只管理平台角色资源;修改已初始化应用的源码(包括当前目录已经是应用项目)时,先查看工作区 `.agents/skills/`,完整读取与任务匹配的领域 skill再按其路由读取所需 reference。角色鉴权或运行态角色管理读应用内 `authz-guide`,不能用本 skill 的平台命令参考推断运行时合同
- `lark-cli` 只用于开发过程中的平台资源核验或变更。应用运行时代码必须使用工程内领域 skill 规定的 SDK禁止通过 `exec` 或子进程调用 `lark-cli`
- 平台回读出的当前资源 ID、名称和成员只用于事实核验不自动构成业务策略除非需求或应用内领域 skill 明确定义,禁止把当前样本硬编码成 allowlist、denylist、只读集合或权限规则。
- 实现领域 SDK 时,以实际包导出的类型和应用内领域 reference 记录的入参、响应路径为准;禁止修改 ambient `.d.ts`、补造宽松类型或强制断言,让猜测的 SDK 结构仅在本地“编译通过”。
- typecheck/build 成功不等于合同正确。交付前逐项核对每个 SDK 调用的入参、响应取值路径和策略分支;涉及更新、删除等不同动作时,分别验证各自动作所需的完整状态,不能复用更弱的前置判断。
- 源码任务交付前确认新增页面、Controller、Module 已接入真实 router/bootstrap并运行项目现有 typecheck/build只创建未接线文件不算完成。
- `+access-scope-*` 只管运行时可见范围(谁能打开应用),不是角色权限;应用协作者/开发权限仍需使用妙搭 Web。自动化触发器请用 `+automation-*`(见「意图路由」)。
## app_id 获取
@@ -101,4 +107,4 @@ lark-cli auth login --domain apps
## 高影响动作:确认与预授权
- **预授权判定**:判断用户是否表达了"放手做完、不用中途逐步问我"的意图——明确免确认(如"别问 / 直接做 / 自己定"),或要求一气呵成做到完成(如"做完部署上线给我")。是 → 整个流程按合理默认往下走、不再逐步确认(含 clone 到派生目录、发布等);否 → 缺失参数(如目录)该问就问、高影响动作先确认。
- **禁止预授权判定底线**(即便已预授权也不豁免):① 会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))先 `--dry-run` 确认;② `+html-publish` 体积超限时(判据见 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)),立即停止并转述超限项。
- **禁止预授权判定底线**(即便已预授权也不豁免):① 会删/丢数据或不可逆的 DB 操作(判据见 [`lark-apps-db-execute.md`](references/lark-apps-db-execute.md))先 `--dry-run` 确认;② `+role-delete``+role-member-remove --all`、批量移除成员必须先确认 app、role、成员范围和后果不能从泛化"直接做"推导出 `--yes`;命令式“删除/移除某对象”只确定操作目标,不等于用户已确认不可逆后果,未明确确认时应在说明影响后停下请求确认;③ `+html-publish` 体积超限时(判据见 [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md)),立即停止并转述超限项。

View File

@@ -37,4 +37,4 @@ lark-cli apps +access-scope-set --app-id app_xxx --scope specific \
若服务端返回"应用未发布/需先发布才能设置可见范围",把这一情况转述给用户并询问是否现在发布,得到同意后再 `+release-create`,不要把这个 hint 当指令自动发布。
用户给的是姓名、部门名或群名时,先解析成 ID 再组装 `--targets`:人名→`ou_``lark-cli contact +search-user --query <名字>`,群名→`oc_``lark-cli im +chat-search --query <群名>`,部门→`od_` 走 contact/通讯录。多候选时展示名称和 ID 让用户选,不要要求用户手填 `ou_` / `od_` / `oc_`
用户给的是姓名、部门名或群名时,先解析成 ID 再组装 `--targets`:人名→`ou_``lark-cli contact +search-user --query <名字>`,群名→`oc_``lark-cli im +chat-search --query <群名>`,部门→`od-` 走 contact/通讯录。多候选时展示名称和 ID 让用户选,不要要求用户手填 `ou_` / `od-` / `oc_`

View File

@@ -2,9 +2,11 @@
经妙搭服务端在应用数据库执行 SQL。运行时命令事实以 `lark-cli apps +db-execute --help` 为准。
> **写 SQL 前先看文末「平台 SQL 规范」**:妙搭底层是 PostgreSQL + 一层平台约束SQL 内容不符合会被服务端直接拒或建出行为不对的表。最容易踩的三条:① 建业务表必须带 4 个审计列(`_created_at`/`_updated_at`/`_created_by`/`_updated_by`+ 启用 RLS + 4 条 policy一次调用里写全② 人员字段用内置复合类型 `user_profile`(写入 `ROW('<user_id>')::user_profile`,查询解引用 `(field).user_id`);③ `CREATE/DROP DATABASE·SCHEMA·USER·ROLE`、非白名单 `CREATE EXTENSION`、平台保留表 `auth`/`users` 会被硬拒,`online` 环境禁 DDL。
## 何时用
用于通过妙搭服务端执行应用数据库 SQL。不要从环境变量里取连接串裸连数据库本地调试也走这个 shortcut。
用于通过妙搭服务端执行应用数据库 SQL。不要从环境变量里取连接串裸连数据库本地调试也走这个 shortcut。写什么样的 SQL平台约束、建表模板、`user_profile`、审计列、禁用 SQL、PG 陷阱)见文末「平台 SQL 规范」。
## 命令骨架
@@ -42,3 +44,185 @@ lark-cli apps +db-execute --app-id app_xxx --environment dev --sql - --yes < /Us
- 多语句失败时,失败前的语句可能已经 commit 落地。不要整批重跑;按错误 message/hint 修失败语句,并从剩余语句继续。
- 如果需要原子性,让用户在 SQL 内显式写 `BEGIN` / `COMMIT`,不要假设 CLI 会包事务。
- 不要把数据库连接串从 env 中取出来裸连。
---
# 平台 SQL 规范
上面讲命令怎么调,这里讲**该写出什么样的 SQL**:妙搭底层是 PostgreSQL + 一层平台约束RLS、审计列、`user_profile` 复合类型、禁用 SQL 白名单),不符合会被服务端直接拒或建出行为不对的表。看表 / 看结构用 [`+db-table-list`/`+db-table-get`](lark-apps-db.md),别手写系统表查询模拟。
## 平台禁用 SQL硬拒绝
以下命中会被服务端拒,`error``type:"api"`)的 message/hint 会说明原因——先按 hint 修再重试,不要反复重试同一句。
| 类别 | 禁止 |
|---|---|
| 数据库级 | `CREATE / DROP / ALTER DATABASE` |
| Schema 级 | `CREATE / DROP SCHEMA` |
| 用户 / 角色级 | `CREATE / DROP USER``CREATE / DROP / ALTER ROLE` |
| Owner 切换 | `REASSIGN OWNED` / `DROP OWNED` |
## 建表规范CREATE TABLE
新建业务表必须4 个审计列 + 启用 RLS + 4 条默认 policy**放在同一次 `+db-execute` 调用里**RLS / policy / COMMENT / INDEX 一起)。裸表名,不写 `public.` 或 schema 前缀。
```sql
CREATE TABLE IF NOT EXISTS <table> (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- ... 业务列 ...
name varchar(100) NOT NULL,
_created_at TIMESTAMP(3) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
_created_by user_profile DEFAULT (
CASE
WHEN current_setting('app.user_id', TRUE) = '' THEN NULL
ELSE concat('(', current_setting('app.user_id', TRUE), ')')::user_profile
END
),
_updated_at TIMESTAMP(3) WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
_updated_by user_profile DEFAULT (
CASE
WHEN current_setting('app.user_id', TRUE) = '' THEN NULL
ELSE concat('(', current_setting('app.user_id', TRUE), ')')::user_profile
END
)
);
ALTER TABLE <table> ENABLE ROW LEVEL SECURITY;
CREATE POLICY service_role_bypass_policy ON <table>
TO service_role USING (true);
CREATE POLICY "修改全部数据" ON <table>
AS PERMISSIVE FOR ALL TO authenticated USING (true);
CREATE POLICY "查看全部数据" ON <table>
AS PERMISSIVE FOR SELECT TO authenticated, anon USING (true);
CREATE POLICY "修改本人数据" ON <table>
AS PERMISSIVE FOR ALL TO authenticated USING (
(current_setting('app.user_id'::text) = ANY (ARRAY[]::text[]))
AND (current_setting('app.user_id'::text) = ((_created_by).user_id)::text)
);
```
建表流程:先 `+db-table-list` / `+db-table-get` 确认表不存在或看现有结构 → 生成 DDL → 向用户展示影响并取得授权 → `+db-execute ... --yes` 执行。
## 审计列
- 平台自动维护的四列固定叫 `_created_at` / `_updated_at` / `_created_by` / `_updated_by`**下划线开头**)。查询 / 排序 / 过滤一律用这些名字,别写 `created_at`
- `_created_at` / `_updated_at` 在 INSERT 时可省略(有默认值);需要业务归属时显式写 `_created_by` / `_updated_by`
- UPDATE 业务字段时建议同步 `_updated_at = CURRENT_TIMESTAMP``_updated_by`
## `user_profile` 复合类型
平台内置类型 `(user_id varchar, name varchar, email varchar, avatar text, status integer)`,无需创建。**业务 SQL 只允许访问 `(field).user_id`**,不要依赖 `name` / `email` / `avatar` / `status`(可能为空或过期)。
```sql
-- 写入 / 更新:用 ROW()::user_profile更新时替换整个字段不改单个属性
INSERT INTO teacher (teacher_profile, class_id)
VALUES (ROW('<user_id>')::user_profile, gen_random_uuid());
UPDATE teacher SET teacher_profile = ROW('<user_id>')::user_profile
WHERE (teacher_profile).user_id = '<old_user_id>';
-- 查询 / 过滤:解引用取 user_idraw SQL 返回给前端前必须解引用,别直接返回复合类型
SELECT (teacher_profile).user_id AS teacher_profile, class_id FROM teacher;
-- 索引 / 唯一性:表达式列用三重括号;表达式唯一性用 CREATE UNIQUE INDEX
-- 不能用 ALTER TABLE ADD CONSTRAINT UNIQUE不支持表达式列
CREATE INDEX idx_teacher_user_id ON teacher (((teacher_profile).user_id));
CREATE UNIQUE INDEX uk_teacher_user_id ON teacher (((teacher_profile).user_id));
```
## DDL 规则
| 场景 | 做法 |
|---|---|
| 加列 | `ALTER TABLE <t> ADD COLUMN IF NOT EXISTS <col> <type>`,相关 `COMMENT ON` 同次执行 |
| 加索引 | `CREATE INDEX IF NOT EXISTS idx_<t>_<cols> ON <t>(...)` |
| JSONB 类型声明 | 必须 `COMMENT ON COLUMN <t>.<col> IS '@type { ... }'` 声明 TypeScript 类型,和 CREATE / ALTER 同次调用 |
| 加 NOT NULL 列 | 必须带 `DEFAULT` 让存量行自动填:`ADD COLUMN <col> <type> NOT NULL DEFAULT <值>` |
| 删表 / 删列 | 有业务数据默认禁止;必须用户明确授权后才执行,并说明数据丢失风险 |
| 强约束 | `UNIQUE` / `FOREIGN KEY` / `NOT NULL` 默认谨慎,不确定不加 |
**多环境库加约束前先查 online 存量**`dev` 干净不代表 `online` 干净,约束发布到 online 会撞线上存量数据而失败。发布前一律先用 `--environment online` 查清楚,按约束类型分三种:
- **加唯一约束(`UNIQUE` / 唯一索引)**:线上不能有重复值。先查重复,有则先清理再加:
```bash
lark-cli apps +db-execute --app-id app_xxx --environment online --sql \
"SELECT <cols>, count(*) FROM t GROUP BY <cols> HAVING count(*) > 1" --yes
```
- **已有列改 `NOT NULL`(收紧约束)**:线上该列不能有 NULL。先查 NULL 行数,有就先回填(`UPDATE t SET <col> = <默认值> WHERE <col> IS NULL`)再加约束:
```bash
lark-cli apps +db-execute --app-id app_xxx --environment online --sql \
"SELECT count(*) FROM t WHERE <col> IS NULL" --yes
```
- **新加 `NOT NULL` 字段**:必须带 `DEFAULT`,且要求线上该表**无存量数据**,否则发布报错。线上已有数据时别直接加,改走三步安全变更:先 `ADD COLUMN <col> <type>`(可空)→ 回填 `UPDATE t SET <col> = <值>` → 再 `ALTER COLUMN <col> SET NOT NULL`。先查线上行数判断走哪条:
```bash
lark-cli apps +db-execute --app-id app_xxx --environment online --sql \
"SELECT count(*) FROM t" --yes
```
## SELECT 规则
| 规则 | 要求 |
|---|------------------------------------------------------------------|
| 行数 | 结果集有硬上限(平台限制 1000 行),超限**报错而非静默截断**;大表必须显式 `LIMIT`、聚合或游标分页 |
| 分页 | 大表优先游标分页 `WHERE id > <last_id> ORDER BY id LIMIT n`,避免大 `OFFSET` |
| user_profile | 返回给前端前解引用:`(owner).user_id AS owner` |
| 统计 | 总数用 `count(*)`、分组用 `GROUP BY`,别把全量拉到 agent 侧再统计 |
| 慢查询 | 用 `EXPLAIN (ANALYZE, BUFFERS)`;大表 Seq Scan 考虑加索引 |
## DML 规则
**INSERT**
- UUID 主键省略,交给 `DEFAULT gen_random_uuid()`;外键 UUID 用子查询取父表 id不手写。
- NOT NULL 且无默认值的列必须给值;批量 INSERT 每行列数一致。
- 需要幂等用 `ON CONFLICT ... DO NOTHING / DO UPDATE`。
- 标量子查询必须保证单行,非唯一条件加 `ORDER BY ... LIMIT 1`。
**UPDATE**
- **必须有明确 `WHERE`,禁止无条件 UPDATE**。
- 用户说「修改 / 更新 / 改一下」数据时用 UPDATE**禁止 DELETE + INSERT** 模式。
- 更新 `user_profile` / 复合类型时替换整个字段。
- 批量更新前影响范围不明确,先 `SELECT count(*)` 给用户确认。
**DELETE / TRUNCATE**属会丢数据的高影响操作按上面「Agent 规则」的确认流程走)
- 已有表 / 已有数据默认禁止;先 `SELECT count(*)` 展示命中行数、取得用户明确授权,再带 `--yes` 执行。
- `TRUNCATE` 影响整表,视同高风险删除。
```sql
UPDATE task
SET status = 'done', _updated_at = CURRENT_TIMESTAMP, _updated_by = ROW('<user_id>')::user_profile
WHERE id = (SELECT id FROM task WHERE title = '梳理需求' ORDER BY _created_at DESC LIMIT 1);
```
## 常见 PostgreSQL 陷阱
| 陷阱 | 正确做法 |
|---|---|
| 表名带 schema 前缀 | 业务表一律裸表名 `FROM orders`,别写 `public.orders` |
| 保留字作标识符 | 避免 `user` / `order` / `desc` / `offset` / `references` 等 |
| 内联 COMMENT | 禁止 `col TEXT COMMENT 'xx'`,用独立 `COMMENT ON COLUMN` |
| 手写系统表查结构 | 常规结构查询用 `+db-table-list` / `+db-table-get`,别手写 `information_schema` / `pg_indexes` 模拟 |
| 空数组类型不明 | 写 `ARRAY[]::text[]` 或 `'{}'::text[]` |
| `ROUND` 报错 | 用 `ROUND(num::numeric, n)` 或 `ROUND(num::double precision)` |
| `DISTINCT` + 窗口函数 | 分两层查询,先 DISTINCT 再窗口函数 |
| MySQL 方言 | 不用 `SHOW TABLES` / `DESCRIBE` / 内联 `COMMENT`;用 `+db-table-*` 和 `COMMENT ON` |
| 多语句以为自动回滚 | `A; B; C` 不自动包事务B 失败时 A 已提交;要原子性显式 `BEGIN; ... COMMIT;`见上「命令骨架」「Agent 规则」) |
## 数据类型与设计
| 项目 | 规则 |
|---|---|
| 主键 | 默认 `id uuid PRIMARY KEY DEFAULT gen_random_uuid()` |
| 命名 | 表名单数、全小写、snake_case、无冗余后缀 |
| 枚举 / 状态 | 用 `varchar(255)`,值用小写英文 + 下划线 |
| JSONB | 必须 `COMMENT ON COLUMN ... IS '@type { ... }'` 声明类型 |
| 附件 / 图片 | URL 用 `TEXT`,命名 `xxx_url` |
| 约束 | `UNIQUE` / `FOREIGN KEY` / `NOT NULL` 默认谨慎,新增 NOT NULL 列优先带 `DEFAULT` |

View File

@@ -4,7 +4,7 @@
## 何时用
用户要看应用里有哪些表 / 某张表的结构、把单库应用拆成 dev/online 多环境、把数据导进导出表、查谁在什么时候改了表结构或表数据、开关行级审计、把开发环境的库结构发布到线上、把库恢复到过去某个时间点、或看数据库用量时。逐条执行 SQL 走 [`+db-execute`](lark-apps-db-execute.md);文件存储(上传/下载文件)走 [`lark-apps-file.md`](lark-apps-file.md)。
用户要看应用里有哪些表 / 某张表的结构、把单库应用拆成 dev/online 多环境、把数据导进导出表、查谁在什么时候改了表结构或表数据、开关行级审计、把开发环境的库结构发布到线上、把库恢复到过去某个时间点、或看数据库用量时。逐条执行 SQL 走 [`+db-execute`](lark-apps-db-execute.md);文件存储(上传/下载文件)走 [`lark-apps-file.md`](lark-apps-file.md)。**建表 / 改表 / 写 SQL 的平台内容规范**审计列、RLS、`user_profile`、禁用 SQL、PG 陷阱)见 [`lark-apps-db-execute.md`](lark-apps-db-execute.md) 的「平台 SQL 规范」。
## 命令一览

View File

@@ -0,0 +1,133 @@
# apps role 域命令(应用角色)
管理妙搭应用内的平台角色、角色成员,以及查询某个用户命中的角色。运行时命令事实以 `lark-cli apps +<cmd> --help` 为准;身份、授权和高风险确认遵循本域 [`SKILL.md`](../SKILL.md)。
## 何时用
用户要列出、查看、创建、更新或删除某个妙搭应用内的平台角色,管理角色的用户、部门或群成员,或查询某个用户在应用中命中的角色时使用。多维表格 / Base 的角色与权限走 `lark-base`;设置谁能访问应用走 `+access-scope-*`,不要路由到本命令域。
## 命令一览
| 命令 | 做什么 | 关键参数 |
|---|---|---|
| `+role-list` | 分页列出角色,或按名称筛选角色 | `--app-id``--name``--page-size`/`--page-token` |
| `+role-get` | 根据真实 `role_id` 读取角色详情 | `--app-id``--role-id` |
| `+role-match-list` | 查询指定用户命中的角色 | `--app-id``--user-id` |
| `+role-create` | 创建角色 | `--app-id``--name``--description``--role-id` |
| `+role-update` | 更新角色名称或描述 | `--app-id``--role-id``--name`/`--description` |
| `+role-delete` | 永久删除角色 | `--app-id``--role-id``--yes` |
| `+role-member-list` | 查询角色的用户、部门和群成员 | `--app-id``--role-id``--member-type` |
| `+role-member-add` | 向角色添加用户、部门或群成员 | `--app-id``--role-id``--users`/`--departments`/`--chats` |
| `+role-member-remove` | 定向移除或清空角色成员 | `--app-id``--role-id`、成员参数或 `--all``--yes` |
## 约定(先读)
- `app_...` 标识的是妙搭应用,其角色和成员只使用 `apps +role-*` / `apps +role-member-*`;不要改走 Base 角色命令或裸 bitable API。
- 角色名称不是 `role_id`。只有名称时优先用 `+role-list --name` 精确解析若已取得完整分页列表也可从中证明精确名称唯一命中。0 条如实报告,多条让用户消歧,唯一命中后才使用返回的真实 ID。
- `+role-list` 返回 `has_more=true` 时,用本页 `page_token` 继续查询,直到 `has_more=false`;不要根据 `total` 补造条目。
- `+role-list``+role-get``+role-match-list` 的角色数据分别位于 `data.items``data.role``data.roles`,不要混用。
- 同一角色的写入及依赖该写入结果的操作必须串行。不同角色的独立操作只有在每次写入可单独追溯、失败不影响其它目标且分别验收时才可并行;否则保持串行。互不依赖的名称解析或只读查询可并行。
## 各命令
### 查询角色
```bash
lark-cli apps +role-list --app-id <app_id> --page-size 100
lark-cli apps +role-list --app-id <app_id> --name '<exact_name>'
lark-cli apps +role-get --app-id <app_id> --role-id <role_id>
lark-cli apps +role-match-list --app-id <app_id> --user-id <ou_x>
```
整理角色列表时保留 `role_id``name``description`。不要猜测未知 `role_id`,也不要从同名候选中静默选择。
`items=[]` 时直接报告当前没有角色;不要为表格补造“无”或 `N/A` 占位行。
`+role-match-list --user-id` 只接受 `ou_...`;用户给的是姓名、邮箱或手机号时,先解析唯一 open ID再查询命中角色。
### 创建与更新
```bash
lark-cli apps +role-create --app-id <app_id> --name '<name>' \
--description '<description>'
# 只修改名称
lark-cli apps +role-update --app-id <app_id> --role-id <role_id> \
--name '<new_name>' --as user --format json
# 只修改描述
lark-cli apps +role-update --app-id <app_id> --role-id <role_id> \
--description '<new_description>' --as user --format json
```
- `--description` 和创建时的 `--role-id` 可选;仅在确实需要稳定 ID 时传 `--role-id`,创建后不能修改。
- 更新时只传用户明确要求变更的字段。
- 成功响应中的角色位于 `data.role`。只有用户要求独立验证,或结果将用于后续高风险操作时,才额外执行 `+role-get`
### 删除角色
普通“删除某角色”请求只说明目标,**不等于不可逆确认**。如果用户尚未明确确认删除后果,本轮只能定位角色、读取完整成员并说明影响,最后请求确认;不得在同一轮自动追加 `--yes`。用户已明确确认不可逆删除时才继续。
只有名称时仍按上述规则唯一解析,优先使用 `+role-list --name`。目标写前已不存在时立即停止,如实说明本次是 no-op、没有执行删除不能把“当前不存在”表述为“删除成功”。
删除前读取准确角色和完整成员范围,向用户说明 app、role、`users` / `departments` / `chats` 影响;得到不可逆删除确认后才使用 `--yes`
```bash
lark-cli apps +role-get --app-id <app_id> --role-id <role_id>
lark-cli apps +role-member-list --app-id <app_id> --role-id <role_id>
lark-cli apps +role-delete --app-id <app_id> --role-id <role_id> --yes
```
成功响应包含匹配的 `data.role_id``data.deleted=true`。只有用户明确要求独立验证删除结果时,才再用 `+role-list --name` 检查目标 ID 已不存在。
### 成员 ID 解析
成员 flags 只接受 open ID用户 `ou_...`、部门 `od-...`、群 `oc_...`。用户已提供对应类型的合法 open ID 时直接使用;只有名称或邮箱时才解析。
对象类型以用户语义为准,不能互换解析器:用户走通讯录用户搜索,部门走部门搜索,群走群搜索。
```bash
# 用户:每个姓名或邮箱单独查询。
lark-cli contact +search-user --query '<姓名或邮箱>' \
--exclude-external-users --page-size 30
# 部门:拉完分页,只接受唯一的 open_department_id。
lark-cli api POST /open-apis/contact/v3/departments/search \
--params '{"user_id_type":"open_id","department_id_type":"open_department_id","page_size":50}' \
--data '{"query":"<部门名称>"}'
# 群:拉完分页,只接受名称精确匹配的唯一 chat_id。
lark-cli im +chat-search --query '<群名称>' --page-size 50
```
- 只接受与输入姓名、邮箱或群名精确匹配的唯一结果;部门搜索只接受完整 query 的唯一 `od-...`。0 条、多条或分页未完成时停止写入并让用户补充或消歧。
- 多个对象逐个解析。全部解析成功且总数不超过 100 后,按类型放入一次成员写入;任一对象失败时不要部分写入,也不要自动拆批。
### 成员操作
```bash
# 省略 --member-type返回完整 users / departments / chats。
lark-cli apps +role-member-list --app-id <app_id> --role-id <role_id>
lark-cli apps +role-member-add --app-id <app_id> --role-id <role_id> \
--users ou_x,ou_y --departments od-x --chats oc_x
lark-cli apps +role-member-remove --app-id <app_id> --role-id <role_id> \
--users ou_x --yes
# 清空成员,不删除角色。
lark-cli apps +role-member-remove --app-id <app_id> --role-id <role_id> \
--all --yes
```
- `+role-member-list` 不分页;`--member-type` 只返回选中类型的字段,未返回的成员字段表示“未查询”而不是空。影响确认或完整比较时必须省略它。
- 汇总 `--member-type` 结果时明确这是过滤投影,不得据此断言角色没有其它类型成员。
- 用户要求 CLI 原生 table 时,直接执行 `+role-member-list --format table`;可原样转发或做事实摘要,不要先取 JSON 再手工重建一张替代表格。
- 写入和依赖其结果的回读不得放进同一个并发批次;必须等待写入完整返回成功后,再单独发起回读。误并发时只能以写入完成后的新回读作为结果证据。
- 添加前仅在用户要求独立证明或确认其他成员类型未变化时读取完整基线,并在写后完整回读;否则成功响应即可作为结果。
- 定向移除前确认准确成员及影响。若需要证明结果,写后完整回读;不要把过滤结果当作完整成员集合。
- `--all` 前读取完整成员范围并确认;成功后执行一次无过滤 `+role-member-list`,确认三个成员数组均为空。
## 权限
| 操作 | 所需 scope |
|---|---|
| list / get / member-list / match-list | `spark:app:read` |
| create / update / delete / member-add / member-remove | `spark:app:write` |

View File

@@ -122,7 +122,7 @@ metadata:
## Dashboard / Workflow / Role
- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,在用户明确要求重排/美化时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`
- Dashboard 的复杂点是 block 的 `data_config`,不是 list/get/create/delete 命令参数。创建或更新 block 前先读 [dashboard-block-data-config.md](references/dashboard-block-data-config.md),组件必须串行创建;`+dashboard-arrange` 是服务端智能布局,在用户明确要求重排/美化、或对本次会话从零新建的仪表盘做收尾整理时执行。`+dashboard-block-get-data` 读取图表最终计算结果,不返回 block 名称、类型、布局或 `data_config`;需要元数据先用 `+dashboard-block-get`
- Workflow 的复杂点是 `steps` 结构。创建、更新或解释完整 workflow 时读入口 [lark-base-workflow-guide.md](references/lark-base-workflow-guide.md) 和 steps JSON SSOT [lark-base-workflow-schema.md](references/lark-base-workflow-schema.md)enable/disable/list 只需确认 workflow ID、当前启停状态和用户意图。
- Role 的复杂点是权限 JSON。角色操作先读入口 [lark-base-role-guide.md](references/lark-base-role-guide.md)`+role-create` 只支持自定义角色;`+role-update` 是 delta merge角色 create/update 或解读完整配置时读权限 JSON SSOT [role-config.md](references/role-config.md)。`+role-delete` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。

View File

@@ -90,6 +90,10 @@ user / created_by / updated_by: is, isNot, isEmpty, isNotEmpty
`sort.order``asc`(升序)/ `desc`(降序)
只要写 `sort` 对象就需要明确排序方向。CLI 会把 `sort.type``group``view` 且缺少 `order` 的情况规范化为 `order:"asc"``sort.type:"value"` 必须显式写 `order:"asc"``order:"desc"`,因为指标值排序方向会改变业务含义。
如果表中行序就是业务顺序,首次创建 block 时就一次性设置 `sort:{"type":"view","order":"asc"}` 保留行序,避免创建后再二次更新排序条件。
示例 — 柱状图按销售额降序:
```json
@@ -169,9 +173,10 @@ user / created_by / updated_by: is, isNot, isEmpty, isNotEmpty
- 长度/结构
- `group_by` 最多 2 个;每项 `field_name` 必填
- `group_by[].sort.type` 取值 `group|value|view``order` 取值 `asc|desc`
- 规范化CLI 自动处理)
- 规范化CLI 自动处理`--no-validate` 时不生效,`data_config` 原样透传给后端
- `series[].rollup` 自动转成大写(如 `sum``SUM`
- `group_by[].sort.type/order` 自动转成小写
- `group_by[].sort.type``group``view` 且缺少 `order` 时,自动补 `order:"asc"``value` 排序不会自动补方向
- 本地校验(可通过 `--no-validate` 跳过)
- `+dashboard-block-create` 默认对 `data_config` 做轻量校验;失败会聚合错误并给出修复建议
- `+dashboard-block-update` 不做强类型校验,由后端验证具体字段
@@ -264,14 +269,35 @@ user / created_by / updated_by: is, isNot, isEmpty, isNotEmpty
漏斗图(流程转化):
先判断用户要看的数值语义:
- **当前数量**:统计每个当前状态/阶段下有多少记录,例如“各环节当前数量”“当前阶段分布”。源表有状态/阶段字段时,直接用 `count_all:true` + `group_by`
- **累计数量**:统计到达该阶段及其后续阶段(后缀和)的累计数量,例如“流程转化”“从 A 到 B 各环节转化”。此口径假设流程单向、无跳阶/回退、记录不删除;不满足时须用状态变更历史,不能对当前快照累加。如果表中已有累计数量字段或阶段汇总表,直接用该字段画漏斗图;否则先计算累计数量,创建并写入 helper 汇总表后再画图。
当前数量:
```json
{
"table_name": "表名",
"series": [{ "field_name": "数值字段", "rollup": "SUM" }],
"count_all": true,
"group_by": [{ "field_name": "状态字段", "mode": "integrated" }]
}
```
累计数量:
```json
{
"table_name": "流程汇总表名",
"series": [{ "field_name": "累计数量", "rollup": "SUM" }],
"group_by": [{ "field_name": "阶段字段", "mode": "integrated", "sort": {"type":"view","order":"asc"} }]
}
```
如果只有当前状态数据但用户要看流程转化,需要先按业务阶段顺序计算每个阶段的累计数量,再创建 helper 汇总表(如:阶段、累计数量),用 `+record-batch-create` 一次写入后按“累计数量”模板创建漏斗图。helper 表行序就是业务顺序时,首次创建 block 时一次性设置好 `group_by.sort`
> ⚠️ 注意:helper 汇总表仅用于源表无法直接聚合出目标形态的场景(如上面的累计数量漏斗图)。只要能在源表上直接用 `group_by` + `rollup`(含 `AVERAGE`)算出,就不需要新建 helper 表。
词云(文本频率):
```json

View File

@@ -19,12 +19,19 @@ Dashboard 是 Base 中的数据可视化看板,可以把表格数据变成**
| 修改组件 | `+dashboard-block-update` | 先读 block 现状,再读 [dashboard-block-data-config.md](dashboard-block-data-config.md) 决定替换哪些顶层 key |
| 查看仪表盘有哪些组件 | `+dashboard-get``+dashboard-block-list` | 本页下方「查看仪表盘」 |
| 读取图表计算结果 | `+dashboard-block-get-data` | 返回图表最终数据协议;需要 block 元数据先用 `+dashboard-block-get` |
| 智能重排组件布局 | `+dashboard-arrange` | 只在用户明确要求重排时执行;无法指定精确位置 |
| 智能重排组件布局 | `+dashboard-arrange` | 用户明确要求重排,或本次会话新建仪表盘的收尾整理;无法指定精确位置 |
## 典型场景工作流
### 场景 1从 0 到 1 创建仪表盘
从 0 到 1 创建仪表盘时,按用户需求规划组件的类型和数量,并注意以下要点:
- 聚合方式:创建指标卡或分布图时优先把聚合写进 `data_config`,只有 Top N、字段取值探索、复杂筛选校验或 helper 汇总表场景才先用 `+data-query`
- Dry-run 边界:已按模板构造的简单指标卡、分布图、趋势图不需要逐个 `--dry-run` 后再真实创建;只有在调试 JSON、检查请求体、复杂自造 `data_config` 或处理 API validation 错误时才 dry-run。
- 验证方式:通过创建接口返回值确认创建成功与否,只在结果不确定时用 `+dashboard-get``+dashboard-block-list` 确认仪表盘和组件存在,或调用 `+dashboard-block-get-data`读取计算结果验证。
- 布局方式:`+dashboard-arrange` 仅两种情况使用:① 用户明确要求美化/重排;② 本次会话中从零新建的仪表盘,建完组件后做一次性布局整理。不是创建成功的必要步骤。
示例:搭建一个销售数据分析仪表盘
```bash
@@ -63,6 +70,7 @@ lark-cli base +dashboard-block-create \
# 第 5 步:组件创建完成后,使用 arrange 命令智能重排布局(可选但推荐)
# 默认布局可能不够美观arrange 会根据组件数量和类型自动优化布局
# 若用户没有要求美化/重排,可先跳过此步骤;这不影响仪表盘和组件是否已创建成功
lark-cli base +dashboard-arrange \
--base-token xxx \
--dashboard-id blk_xxx
@@ -125,11 +133,12 @@ lark-cli base +dashboard-block-update \
--dashboard-id blk_xxx \
--block-id chtxxxxxxxx \
--data-config '{...}'
```
### 场景 4重排仪表盘布局
当用户明确要求对已有仪表盘进行布局重排或美化时使用。
当用户明确要求对已有仪表盘进行布局重排或美化时使用(对本次会话从零新建的仪表盘,可在建完组件后直接做一次性整理,见场景 1
> [!CAUTION]
> - 排列结果是**服务端智能推荐**,不一定完全符合用户预期

View File

@@ -347,28 +347,30 @@ value 使用预定义关键字机制,第一个元素为字符串常量名称
|------|------|------|------|
| `format` | string | 是 | 固定为 `"flat"`,表示返回扁平化的对象数组 |
## API 出参详情
## CLI 出参详情
CLI 输出标准信封 `{ok, identity, data}`(失败时为 `{ok:false, identity, error}`)。
**成功时:**
```json
{"code": 0, "data": {"main_data": [{"dim_city": {"value": "北京"}, "total_amount": {"value": 12345.00}}, ...]}, "msg": ""}
{"ok": true, "identity": "user", "data": {"main_data": [{"dim_city": {"value": "北京"}, "total_amount": {"value": 12345.00}}, ...]}}
```
**失败时:**
```json
{"code": 800004006, "data": {"error": {"code": 800004006, ...}}, "msg": "DSL validation failed"}
{"ok": false, "identity": "user", "error": {"type": "api", "subtype": "unknown", "code": 800004006, "message": "...does not exist in table schema", "hint": "...", "log_id": "..."}}
```
**Response 字段:**
| 字段 | 类型 | 说明 |
|------|------|------|
| `code` | int | 状态码0 为成功 |
| `msg` | string | 错误信息 |
| `data.main_data` | []object | 查询结果数组,每个元素为一行数据 |
| `data.error` | object | 失败时的错误详情 |
| `ok` | bool | 是否成功 |
| `identity` | string | 执行身份:`user` / `bot` |
| `data.main_data` | []object | 查询结果数组,每个元素为一行数据(成功时) |
| `error` | object | 失败时的 typed 错误,含 `type` / `subtype` / `code` / `message` / `hint` / `log_id` |
每行数据的字段值封装在 CellValue 中:

View File

@@ -1,7 +1,7 @@
---
name: lark-drive
version: 1.0.0
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。"
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。"
metadata:
requires:
bins: ["lark-cli"]
@@ -20,10 +20,12 @@ metadata:
## 快速决策
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:切到 `lark-wiki`,使用 `lark-cli wiki +move-to-drive`;不要把 Wiki token 直接交给 `drive +move`。这是会改变文档归属和权限继承的写操作,执行前确认源节点与目标位置。
- 用户要**复制文档 / 创建副本 / 另存为副本**时,使用 `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。
- 用户要**检查 / 治理文档权限、公开范围、链接分享、外部访问、复制下载权限、密级标签、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 文件治理能力。
- 用户要**整理云盘 / 文件夹 / 文档库 / 知识库 / 个人文档库**,或要“盘点目录结构、找出未归档/临时/重复/空目录、生成整理方案”,必须先阅读 [`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,16 +20,20 @@
若缺少任一条件,使用 `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 \
@@ -52,22 +56,30 @@ lark-cli drive +delete \
## 行为说明
- **普通文件删除**:同步操作,成功时直接返回 `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`
- **删除可能需要等待**删除操作在服务端可能异步处理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 后重试 |
## 推荐续跑方式
```bash
# 第一步:先直接删除文件夹
# 第一步:先直接删除资源
lark-cli drive +delete \
--file-token <FOLDER_TOKEN> \
--type folder \
--file-token <FILE_OR_FOLDER_TOKEN> \
--type <TYPE> \
--yes
# 如果返回 ready=false / timed_out=true,再继续查
# 只有返回 ready=false / timed_out=true 或 next_command 时,才需要继续查
lark-cli drive +task_result \
--scenario task_check \
--task-id <TASK_ID>

View File

@@ -5,15 +5,16 @@
将文件或文件夹移动到用户云空间(云盘/云存储)的其他位置。
## 与 `wiki +move` 的区别
## 与 Wiki 移动 shortcut 的区别
- `drive +move` 只处理 **Drive 文件夹树内部** 的位置调整,目标位置用 `--folder-token` 表示
- `wiki +move` 处理的是 **Wiki 知识空间 / 页面层级**:要么移动已有 Wiki 节点,要么把 Drive 文档迁入 Wiki
- 如果用户说“移动到某个文件夹”“移动到我的空间根目录”,应使用 `drive +move`
- `wiki +move-to-drive`**已有 Wiki 节点移出知识库**,放到 Drive 文件夹或“我的空间根目录
- 如果用户说“移动到某个文件夹”“移动到我的空间根目录”,还要判断源对象:源对象已在 Drive 时使用 `drive +move`;源对象是 Wiki 节点时使用 `wiki +move-to-drive`
- 如果用户说“移动到某个知识库 / 页面下”“迁入 Wiki / 知识空间”,应使用 `wiki +move`
- 如果用户说“移动到我的文档库 / 我的知识库 / 个人知识库 / my_library”不要使用 `drive +move`;先按 Wiki 目标处理
- `我的文档库` 不是 Drive root folder也不是 `--folder-token` 省略后的默认目的地
- `drive +move` 不支持 wiki 文档;如果目标是 Wiki不要尝试用 `drive +move` 代替
- `drive +move` 不支持 Wiki 文档;Wiki 节点到 Drive 应使用 `wiki +move-to-drive`,目标是 Wiki 时使用 `wiki +move`
## 不要误用到 `我的文档库`
@@ -117,4 +118,5 @@ lark-cli drive +task_result \
## 参考
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [wiki +move-to-drive](../../lark-wiki/references/lark-wiki-move-to-drive.md) -- 将 Wiki 节点移出知识库并放入 Drive
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -3,7 +3,7 @@
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
查询异步任务结果。该 shortcut 聚合了导入、导出、移动/删除文件夹、Wiki 节点 / 文档迁入 Wiki 等多种异步任务的结果查询,统一接口方便调用。
查询异步任务结果。该 shortcut 聚合了导入、导出、Drive 文件/文件夹移动/删除、Wiki 节点 / 文档迁入 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>
@@ -41,6 +41,11 @@ lark-cli drive +task_result \
--scenario wiki_move \
--task-id <TASK_ID>
# 查询 Wiki 节点移出知识库任务结果wiki +move-to-drive 异步超时后的续跑)
lark-cli drive +task_result \
--scenario wiki_move_to_drive \
--task-id <TASK_ID>
# 查询 Wiki 删除知识空间任务结果wiki +delete-space 异步超时后的续跑)
lark-cli drive +task_result \
--scenario wiki_delete_space \
@@ -51,9 +56,9 @@ lark-cli drive +task_result \
| 参数 | 必填 | 说明 |
|------|------|------|
| `--scenario` | 是 | 任务场景,可选值:`import` (导入任务)、`export` (导出任务)、`task_check` (移动/删除文件夹任务)、`wiki_move` (Wiki 移动任务)、`wiki_delete_space` (Wiki 删除知识空间任务) |
| `--scenario` | 是 | 任务场景,可选值:`import` (导入任务)、`export` (导出任务)、`task_check` (Drive 文件/文件夹移动/删除任务)、`wiki_move` (Wiki 移动任务)、`wiki_move_to_drive` (Wiki 节点移出知识库任务)、`wiki_delete_space` (Wiki 删除知识空间任务)、`wiki_delete_node` (Wiki 删除节点任务) |
| `--ticket` | 条件必填 | 异步任务 ticket**import/export 场景必填** |
| `--task-id` | 条件必填 | 异步任务 ID**task_check / wiki_move / wiki_delete_space 场景必填** |
| `--task-id` | 条件必填 | 异步任务 ID**task_check 及所有 wiki 场景必填**;必须原样传递完整 ID |
| `--file-token` | 条件必填 | 导出任务对应的源文档 token**export 场景必填** |
## 场景说明
@@ -62,9 +67,11 @@ lark-cli drive +task_result \
|------|------|----------|
| `import` | 文档导入任务(如将本地文件导入为云文档) | `--ticket` |
| `export` | 文档导出任务(如云文档导出为 PDF/Word | `--ticket``--file-token` |
| `task_check` | 文件夹移动/删除任务 | `--task-id` |
| `task_check` | Drive 文件/文件夹移动/删除任务 | `--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` |
| `wiki_delete_node` | Wiki 删除节点任务(`wiki +node-delete` 的异步流程,超时后续跑用) | `--task-id` |
## 返回结果
@@ -196,6 +203,29 @@ lark-cli drive +task_result \
- `space_id``obj_token``obj_type``title` 等:从首个 `move_results[0].node` 平铺到顶层,方便直接引用
- `move_results`: 保留完整列表(适用于一次任务移动多个文档的场景)
### Wiki_move_to_drive 场景返回
```json
{
"scenario": "wiki_move_to_drive",
"task_id": "<OPAQUE_TASK_ID>",
"ready": true,
"failed": false,
"status": 0,
"status_msg": "success",
"obj_token": "doxcnXXX",
"obj_type": "docx",
"url": "https://example.feishu.cn/docx/doxcnXXX"
}
```
**字段说明:**
- `ready`: `move_wiki_to_docs_result.status=0` 时为 `true`
- `failed`: `status<0` 时为 `true``status=1` 表示仍在处理
- `status` / `status_msg`: 协议返回的数值状态与可读消息;不要把字符串状态当作成功值解析
- `obj_token` / `obj_type` / `url`: 成功后新 Drive 文档的资源信息
- `task_id`: 签名后的 opaque ID可能包含多个连字符服务端响应省略 `task.task_id` 时回退为请求中的完整 ID
### Wiki_delete_space 场景返回
```json
@@ -256,6 +286,26 @@ lark-cli drive +task_result --scenario wiki_move --task-id <TASK_ID> --as user
> **身份保持一致**:续跑命令的 `--as` 必须与原 `wiki +move` 调用一致;`wiki +move` 的 `next_command` 已自动带上正确的 `--as`。
### 配合 wiki +move-to-drive 使用
```bash
# 1. 把 Wiki 节点移到 Drive 文件夹;省略 --folder-token 表示当前身份的“我的空间”根目录
lark-cli wiki +move-to-drive \
--node-token <WIKI_NODE_TOKEN> \
--folder-token <TARGET_FOLDER_TOKEN> \
--as user
# 若轮询窗口内完成:直接返回 ready=true、obj_token、obj_type 和 url
# 若轮询窗口结束仍未完成:返回 ready=false、完整 task_id、timed_out=true 和 next_command
# 2. 使用完整 task_id 和相同身份续跑
lark-cli drive +task_result \
--scenario wiki_move_to_drive \
--task-id <COMPLETE_TASK_ID> \
--as user
```
> **调用上下文和 ID 都要保持原样**:续跑的 `--profile` 与 `--as` 必须与初始移动一致;`task_id` 可能包含多个连字符,不要拆分或截断。`wiki +move-to-drive` 返回的 `next_command` 会保留 profile 与身份。
### 配合 wiki +delete-space 使用
```bash
@@ -291,7 +341,9 @@ lark-cli drive +export-download --file-token <EXPORTED_FILE_TOKEN>
| export | `drive:drive.metadata:readonly` |
| task_check | `drive:drive.metadata:readonly` |
| wiki_move | `wiki:space:read` |
| wiki_move_to_drive | `wiki:space:read` |
| wiki_delete_space | `wiki:space:read` |
| wiki_delete_node | `wiki:space:read` |
> [!NOTE]
> `import` 场景在 `--as bot` 且任务最终就绪时,还可能额外尝试一次协作者授权;如果 `permission_grant.status = failed`,请根据失败信息检查应用是否具备相应的文档协作者授权能力。
@@ -299,4 +351,5 @@ lark-cli drive +export-download --file-token <EXPORTED_FILE_TOKEN>
## 参考
- [lark-drive](../SKILL.md) -- 云空间(云盘/云存储)全部命令
- [wiki +move-to-drive](../../lark-wiki/references/lark-wiki-move-to-drive.md) -- 将 Wiki 节点移出知识库并放入 Drive
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -1464,6 +1464,7 @@
<xs:annotation>
<xs:documentation>
表格元素, 用于展示结构化数据
宽高分配规则参考 HTML table 的整体值 / 子项值处理逻辑, 但在 SXSD 中做确定化约束, 以保证跨端实现一致。
边框规则:
- 后设置优先:相邻单元格线条只有一个颜色, 如果均设置, 则右下单元格的设置覆盖左上单元格
- 不设置边框属性时使用默认样式
@@ -1476,6 +1477,8 @@
- id: 表格唯一标识符(可选)
- topLeftX/topLeftY: 左上角坐标
- flipX/flipY: 水平/垂直翻转
- width: 表格目标总宽度(可选)。若设置, 优先用于为未填写列宽的列分配剩余宽度; 当所有列宽均为空时, 所有列均分该值; 当所有列均已填写且该值大于已填写列宽总和时, 多出空间继续分配给现有列, 默认按各列当前宽度作为权重分配; 当已填写列宽之和超过或无法容纳该值时, 保留已填写列宽, 并以最终列宽总和回写 table.width
- height: 表格目标总高度(可选)。若设置, 优先用于为未填写行高的行分配剩余高度; 当所有行高均为空时, 所有行均分该值; 当所有行均已填写且该值大于已填写行高总和时, 多出空间继续分配给现有行, 默认按各行当前高度作为权重分配; 当已填写行高之和超过或无法容纳该值时, 保留已填写行高, 并以最终行高总和回写 table.height
table 子元素:
- colgroup: 列组元素, 用于定义列的宽度
- tr: 行元素, 包含多个单元格
@@ -1484,10 +1487,10 @@
- col: 列元素
col 属性:
- span: 列跨数, 默认为1, 可选
- width: 列宽度, 默认值为110, 可选
- width: 列宽度输入值, 默认值为110, 可选。无 table.width 时, 已填写列保持原值, 空列使用默认值; 有 table.width 时, 已填写列保持原值, 空列优先均分剩余宽度; 若不存在空列且 table.width 大于已填写列宽总和, 则多出空间按各列当前宽度作为权重分配到所有列; 若剩余宽度不足则空列回退为默认值, 并以最终列宽总和作为 table.width
tr 属性:
- height: 行高, 默认为单元格高度
- height: 行高输入值, 默认值为37, 可选。无 table.height 时, 已填写行保持原值, 空行使用默认值; 有 table.height 时, 已填写行保持原值, 空行优先均分剩余高度; 若不存在空行且 table.height 大于已填写行高总和, 则多出空间按各行当前高度作为权重分配到所有行; 若剩余高度不足则空行回退为默认值, 并以最终行高总和作为 table.height。若行高低于内容高度, 需要手动修改行高
tr 子元素:
- td: 单元格元素, 用于显示数据
@@ -1543,6 +1546,8 @@
<xs:attribute name="topLeftY" type="sml:YType" use="required"/>
<xs:attribute name="flipX" type="xs:boolean" use="optional" default="false"/>
<xs:attribute name="flipY" type="xs:boolean" use="optional" default="false"/>
<xs:attribute name="width" type="sml:PositiveSize" use="optional"/>
<xs:attribute name="height" type="sml:PositiveSize" use="optional"/>
</xs:complexType>
</xs:element>

View File

@@ -248,6 +248,26 @@
- `<tr>` 内为 `<td>`
- `<td>` 内可放 `<content>`
`<table>` 可选设置 `width``height`,分别表示表格的目标总宽度和总高度:
```xml
<table topLeftX="80" topLeftY="120" width="800" height="300">
<colgroup>
<col width="240"/>
<col/>
</colgroup>
<tr height="80">
<td><content textType="body"><p>表头 1</p></content></td>
<td><content textType="body"><p>表头 2</p></content></td>
</tr>
</table>
```
- 已设置的列宽和行高优先保留;未设置的列宽、行高优先使用目标总宽度或总高度分配剩余空间。
- 如果所有列宽或行高都未设置,则目标总宽度或总高度会在各列或各行之间分配。
- 如果目标尺寸不足以容纳已设置的尺寸,则保留已设置值,并以最终列宽或行高总和为准。
- 行高低于单元格内容高度时,需要手动增大行高。
### `<chart>`
图表元素必须至少包含:

View File

@@ -1,6 +1,6 @@
---
name: lark-wiki
version: 1.0.2
version: 1.0.3
description: "飞书知识库:管理知识空间、空间成员和文档节点。创建和查询知识空间、查看和管理空间成员、管理节点层级结构、在知识库中组织文档和快捷方式。当用户需要在知识库中查找或创建文档、浏览知识空间结构、查看或管理空间成员、移动或复制节点时使用。当用户给出 doubao.com 的 /wiki/ URL/token 时,也应直接使用本 skill不要因为域名不是飞书而回退到 WebFetch路由依据是 URL 路径模式和 token而不是域名。不负责上传文件到知识库节点下走 lark-drive、编辑文档/表格/Base 内容(走 lark-doc / lark-sheets / lark-base。"
metadata:
requires:
@@ -25,6 +25,7 @@ metadata:
## 快速决策
- 用户要**整理 / 盘点 / 归类 / 重构知识库、个人文档库、文档库目录或 Wiki 节点结构**,或要生成整理方案、目标目录树、移动计划时,不要只使用 Wiki 节点 API。必须先阅读 [`../lark-drive/references/lark-drive-workflow.md`](../lark-drive/references/lark-drive-workflow.md),再按其中 `Workflow Registry` 进入 [`knowledge_organize`](../lark-drive/references/lark-drive-workflow-knowledge-organize.md) workflow该 workflow 负责 Drive / Wiki / 个人文档库的统一入口解析、资源盘点、分类计划、写前确认和结果验证。
- 用户要把**已有 Wiki 节点移出知识库,放到 Drive 文件夹或“我的空间”根目录**:使用 `wiki +move-to-drive`,不要使用 `wiki +move``drive +move`。这是会改变节点归属和权限继承的写操作,执行前确认源节点与目标位置。
- 用户给的是知识库 URL`.../wiki/<token>`),且后续要查成员/加成员/删成员:先调用 `lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}'` 获取 `space_id`,后续成员接口统一使用 `space_id`
- 用户要**删除**知识空间(`wiki +delete-space`)但只给了名称或 URL**不能**把名称 / URL 原样传给 `--space-id`,必须先解析出真实 `space_id`。解析方式:
- URL`.../wiki/<token>``lark-cli wiki spaces get_node --params '{"token":"<wiki_token>"}' --format json`,读 `data.node.space_id`
@@ -49,6 +50,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`
| Shortcut | 说明 |
|----------|------|
| [`+move`](references/lark-wiki-move.md) | Move a wiki node, or move a Drive document into Wiki |
| [`+move-to-drive`](references/lark-wiki-move-to-drive.md) | Move a wiki node to a Drive folder and poll the async task |
| [`+node-create`](references/lark-wiki-node-create.md) | Create a wiki node with automatic space resolution |
| [`+delete-space`](references/lark-wiki-delete-space.md) | Delete a wiki space, polling the async delete task when needed |
| [`+space-list`](references/lark-wiki-space-list.md) | List all wiki spaces accessible to the caller |
@@ -76,7 +78,7 @@ Shortcut 是对常用操作的高级封装(`lark-cli wiki +<verb> [flags]`
- `我的文档库` / `My Document Library` / `我的知识库` / `个人知识库` / `my_library` 都应视为 **Wiki personal library**,不是 Drive 根目录
- 处理这类目标时,先解析 `my_library` 对应的真实 `space_id`,再执行 `wiki +move``wiki +node-create` 或其他 Wiki 写操作
- 不要因为缺少显式 `space_id` 就退化成 `drive +move`
- 如果用户明确说的是 Drive 文件夹、云空间(云盘/云存储)根目录、`我的空间`才进入 Drive 域处理
- 如果用户明确说的是 Drive 文件夹、云空间(云盘/云存储)根目录、`我的空间`再按源对象分流:源对象是 Wiki 节点时用 `wiki +move-to-drive`,源对象已在 Drive 时用 `drive +move`
## API Resources

View File

@@ -0,0 +1,122 @@
# wiki +move-to-drive
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md) 了解认证、全局参数和安全规则。
将已有 Wiki 节点移出知识库,并放到指定 Drive 文件夹省略目标文件夹时放到当前调用身份的“我的空间”根目录。该操作始终创建异步任务shortcut 会自动有限轮询。
## 何时使用
| 源对象 | 目标位置 | 命令 |
|--------|----------|------|
| Wiki 节点 | Wiki 空间或 Wiki 父节点 | `wiki +move` |
| Drive 文档 | Wiki 空间或 Wiki 父节点 | `wiki +move` |
| Wiki 节点 | Drive 文件夹或“我的空间”根目录 | `wiki +move-to-drive` |
| Drive 文件 / 文件夹 | Drive 文件夹或根目录 | `drive +move` |
`--node-token` 必须是 Wiki 节点 token不是底层文档的 `obj_token`。无法判断时,先执行 `wiki +node-get --node-token <URL_OR_TOKEN>`
## 命令
```bash
# 移到指定 Drive 文件夹
lark-cli wiki +move-to-drive \
--node-token <WIKI_NODE_TOKEN> \
--folder-token <TARGET_FOLDER_TOKEN> \
--as user
# 移到当前调用身份的“我的空间”根目录
lark-cli wiki +move-to-drive \
--node-token <WIKI_NODE_TOKEN> \
--as user
# 预览提交任务和轮询任务两步请求
lark-cli wiki +move-to-drive \
--node-token <WIKI_NODE_TOKEN> \
--folder-token <TARGET_FOLDER_TOKEN> \
--dry-run
```
## 参数
| 参数 | 必填 | 说明 |
|------|------|------|
| `--node-token` | 是 | 要移出知识库的 Wiki 节点 token |
| `--folder-token` | 否 | 目标 Drive 文件夹 token省略时移动到当前调用身份的“我的空间”根目录 |
## 异步协议与续跑
shortcut 会按以下协议执行:
1. `POST /open-apis/wiki/v2/nodes/{node_token}/move_wiki_to_docs`,取得完整、不可拆分的 `task_id`
2. `GET /open-apis/wiki/v2/tasks/{task_id}?task_type=move_wiki_to_docs`
3. 读取 `data.task.move_wiki_to_docs_result``status=1` 表示处理中,`status=0` 表示成功,`status=-1` 表示失败。
任务查询必须使用 `task_type=move_wiki_to_docs``move_wiki_to_docs_result` 和数值状态;不要回退到其他 task type、result 字段或字符串状态。
- 最多轮询 30 次,每次间隔 2 秒。
- 轮询窗口内成功时返回 `ready=true`,并尽可能返回 `obj_token``obj_type``url`
- 仍在处理中时返回 `ready=false``timed_out=true`、完整 `task_id``next_command`;超时不代表任务失败。
- 任务进入失败态时返回结构化错误。
- `task_id` 是服务端签名的 opaque ID可能包含多个连字符必须原样保存不能自行切分。
- 续跑必须保持和初始移动相同的 `--profile``--as user|bot` 身份否则可能收到权限错误shortcut 返回的 `next_command` 会保留两者。
手动续跑命令:
```bash
lark-cli drive +task_result \
--scenario wiki_move_to_drive \
--task-id <COMPLETE_TASK_ID> \
--as user
```
## 典型返回
成功:
```json
{
"node_token": "wikcnXXX",
"folder_token": "fldcnXXX",
"task_id": "<OPAQUE_TASK_ID>",
"ready": true,
"failed": false,
"status": 0,
"status_msg": "success",
"obj_token": "doxcnXXX",
"obj_type": "docx",
"url": "https://example.feishu.cn/docx/doxcnXXX"
}
```
轮询窗口超时:
```json
{
"node_token": "wikcnXXX",
"folder_token": "",
"task_id": "<OPAQUE_TASK_ID>",
"ready": false,
"failed": false,
"status": 1,
"status_msg": "processing",
"timed_out": true,
"next_command": "lark-cli drive +task_result --scenario wiki_move_to_drive --task-id <OPAQUE_TASK_ID> --as user"
}
```
## 权限与影响
- CLI 写操作预检查使用 `space:document:move`,任务轮询使用 `wiki:space:read`
- 调用方必须能移动源 Wiki 节点并写入目标 Drive 文件夹。
- 成功后源节点会从 Wiki 树中消失,目标文档改用 Drive 目标位置的权限模型;原 Wiki 层级继承权限不再保留。
- 省略 `--folder-token` 时,“根目录”属于当前 `--as` 身份user 与 bot 的可见资源范围可能不同。
> [!CAUTION]
> 这是会改变文档归属和权限继承的**写入操作**。执行前必须确认源 Wiki 节点、目标 Drive 位置和调用身份。
## 参考
- [lark-wiki](../SKILL.md) -- 知识库全部命令
- [wiki +move](lark-wiki-move.md) -- Wiki 内移动与 Drive 文档迁入 Wiki
- [drive +task_result](../../lark-drive/references/lark-drive-task-result.md) -- 超时后的任务续跑
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数

View File

@@ -9,18 +9,19 @@
`docs_to_wiki` 返回 `task_id`shortcut 会先轮询一小段时间;如果轮询窗口内仍未完成,会返回 `next_command`,让调用方继续执行 `lark-cli drive +task_result --scenario wiki_move --task-id <TASK_ID>`
## 与 `drive +move` 的区别
## 与 `wiki +move-to-drive` / `drive +move` 的区别
- `wiki +move` 的目标是 **知识空间或 Wiki 父节点**,使用 `--target-space-id` / `--target-parent-token`
- `wiki +move-to-drive`**已有 Wiki 节点移出知识库,放入 Drive 文件夹或“我的空间”根目录**,使用 `--folder-token`
- `drive +move` 的目标是 **Drive 文件夹**,使用 `--folder-token`
- 如果源对象已经是 Wiki 节点,必须使用 `wiki +move`,而不是 `drive +move`
- 如果源对象已经是 Wiki 节点:目标仍是 Wiki 时使用 `wiki +move`;目标是 Drive 文件夹或根目录时使用 `wiki +move-to-drive`
- 如果源对象还是 Drive 文档,但用户要“迁入知识库”“挂到某个 Wiki 页面下”,也应使用 `wiki +move`
- 如果用户只是想整理云空间(云盘/云存储)文件夹,把文件/文件夹挪到另一个 Drive 文件夹,应使用 `drive +move`
## 口语目标识别
- 当用户说“移动到某个知识库”“挂到某个页面下”“迁入 Wiki”时**Wiki 目标** 处理,优先使用 `wiki +move`
- 当用户说“移动到某个文件夹”“移动到云空间(云盘/云存储)根目录”时,按 **Drive 文件夹目标** 处理,优先使用 `drive +move`
- 当用户说“移动到某个文件夹”“移动到云空间(云盘/云存储)根目录”时,按 **Drive 文件夹目标** 处理;源对象是 Wiki 节点时使用 `wiki +move-to-drive`,源对象已在 Drive 时使用 `drive +move`
- 当用户说“移动到我的文档库”“移动到我的知识库”“放到个人知识库”时,应先按 **Wiki 个人知识库目标** 理解,而不是直接退化成 `drive +move`
- 遇到“我的文档库”这类表述时,可以把它理解成:先用 `my_library` 去查询用户个人知识库,再拿到真实 `space_id`
- 推荐做法是先执行 `lark-cli wiki spaces get --params '{"space_id":"my_library"}'`,取回真实知识库 `space_id`,再把这个 `space_id` 用到 `wiki +move`
@@ -180,4 +181,5 @@ CLI 会在执行前做本地 scope 预检查;当前 shortcut 声明的权限
- [lark-wiki](../SKILL.md) -- 知识库全部命令
- [lark-shared](../../lark-shared/SKILL.md) -- 认证和全局参数
- [wiki +move-to-drive](lark-wiki-move-to-drive.md) -- 将 Wiki 节点移出知识库并放入 Drive
- [drive +task_result](../../lark-drive/references/lark-drive-task-result.md) -- docs-to-wiki 异步任务的续跑查询命令

View File

@@ -0,0 +1,789 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package apps
import (
"context"
"os"
"strings"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestAppsRoleManagementDryRun_RequestShapes(t *testing.T) {
setAppsRoleDryRunEnv(t)
tests := []struct {
name string
args []string
wantMethod string
wantURL string
assertShape func(t *testing.T, stdout string)
}{
{
name: "RoleList_NameFilterShowsFirstAutomaticScanRequest",
args: []string{"apps", "+role-list", "--app-id", "app_role_e2e", "--name", "admin", "--page-size", "20", "--page-token", "40", "--dry-run"},
wantMethod: "GET",
wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles",
assertShape: func(t *testing.T, stdout string) {
assert.Equal(t, "admin", gjson.Get(stdout, "api.0.params.name").String(), "stdout:\n%s", stdout)
assert.Equal(t, int64(100), gjson.Get(stdout, "api.0.params.limit").Int(), "name scan uses maximum backend page size; stdout:\n%s", stdout)
assert.Equal(t, int64(0), gjson.Get(stdout, "api.0.params.offset").Int(), "name scan starts from the first backend page; stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.params.page_size").Exists(), "CLI page-size must be mapped to backend limit; stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.params.page_token").Exists(), "CLI page-token must be mapped to backend offset; stdout:\n%s", stdout)
},
},
{
name: "RoleList_DefaultsPageSizeTo20",
args: []string{"apps", "+role-list", "--app-id", "app_role_e2e", "--dry-run"},
wantMethod: "GET",
wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles",
assertShape: func(t *testing.T, stdout string) {
assert.Equal(t, int64(20), gjson.Get(stdout, "api.0.params.limit").Int(), "stdout:\n%s", stdout)
assert.Equal(t, int64(0), gjson.Get(stdout, "api.0.params.offset").Int(), "stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.params.page_size").Exists(), "CLI page-size must be mapped to backend limit; stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.params.page_token").Exists(), "CLI page-token must be mapped to backend offset; stdout:\n%s", stdout)
},
},
{
name: "RoleGet_UsesRolePathSegment",
args: []string{"apps", "+role-get", "--app-id", "app_role_e2e", "--role-id", "role_admin", "--dry-run"},
wantMethod: "GET",
wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_admin",
assertShape: func(t *testing.T, stdout string) {
assert.False(t, gjson.Get(stdout, "api.0.params").Exists(), "role-get should not send query params; stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.body").Exists(), "role-get should not send body; stdout:\n%s", stdout)
},
},
{
name: "RoleCreate_WithExplicitRoleID",
args: []string{"apps", "+role-create", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--name", "Data Analyst", "--description", "Can inspect BI reports", "--dry-run"},
wantMethod: "POST",
wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles",
assertShape: func(t *testing.T, stdout string) {
assert.Equal(t, "role_analyst", gjson.Get(stdout, "api.0.body.role_id").String(), "stdout:\n%s", stdout)
assert.Equal(t, "Data Analyst", gjson.Get(stdout, "api.0.body.name").String(), "stdout:\n%s", stdout)
assert.Equal(t, "Can inspect BI reports", gjson.Get(stdout, "api.0.body.description").String(), "stdout:\n%s", stdout)
},
},
{
name: "RoleCreate_OmitsRoleIDWhenServerGeneratesIt",
args: []string{"apps", "+role-create", "--app-id", "app_role_e2e", "--name", "Generated Role", "--dry-run"},
wantMethod: "POST",
wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles",
assertShape: func(t *testing.T, stdout string) {
assert.Equal(t, "Generated Role", gjson.Get(stdout, "api.0.body.name").String(), "stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.body.role_id").Exists(), "role_id should be omitted unless explicitly provided; stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.body.description").Exists(), "description should be omitted unless explicitly provided; stdout:\n%s", stdout)
},
},
{
name: "RoleCreate_PreservesExplicitEmptyDescription",
args: []string{"apps", "+role-create", "--app-id", "app_role_e2e", "--name", "Empty Description", "--description", "", "--dry-run"},
wantMethod: "POST",
wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles",
assertShape: func(t *testing.T, stdout string) {
assert.Equal(t, "Empty Description", gjson.Get(stdout, "api.0.body.name").String(), "stdout:\n%s", stdout)
assert.True(t, gjson.Get(stdout, "api.0.body.description").Exists(), "description should be present when explicitly set empty; stdout:\n%s", stdout)
assert.Equal(t, "", gjson.Get(stdout, "api.0.body.description").String(), "stdout:\n%s", stdout)
},
},
{
name: "RoleUpdate_SendsOnlyExplicitFields",
args: []string{"apps", "+role-update", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--description", "Updated description", "--dry-run"},
wantMethod: "PATCH",
wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_analyst",
assertShape: func(t *testing.T, stdout string) {
assert.Equal(t, "Updated description", gjson.Get(stdout, "api.0.body.description").String(), "stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.body.name").Exists(), "name should be omitted when not explicitly provided; stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.body.role_id").Exists(), "role_id is immutable and must not be sent in update body; stdout:\n%s", stdout)
},
},
{
name: "RoleUpdate_PreservesExplicitEmptyDescription",
args: []string{"apps", "+role-update", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--description", "", "--dry-run"},
wantMethod: "PATCH",
wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_analyst",
assertShape: func(t *testing.T, stdout string) {
assert.True(t, gjson.Get(stdout, "api.0.body.description").Exists(), "description should be present when explicitly set empty; stdout:\n%s", stdout)
assert.Equal(t, "", gjson.Get(stdout, "api.0.body.description").String(), "stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.body.name").Exists(), "name should be omitted when not explicitly provided; stdout:\n%s", stdout)
},
},
{
name: "RoleDelete_RequiresHighRiskConfirmationAndUsesDelete",
args: []string{"apps", "+role-delete", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--yes", "--dry-run"},
wantMethod: "DELETE",
wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_analyst",
assertShape: func(t *testing.T, stdout string) {
assert.False(t, gjson.Get(stdout, "api.0.body").Exists(), "role-delete should not send a request body; stdout:\n%s", stdout)
},
},
{
name: "RoleMemberList_UsesMemberTypeOnly",
args: []string{"apps", "+role-member-list", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--member-type", "user", "--dry-run"},
wantMethod: "GET",
wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_analyst/member_list",
assertShape: func(t *testing.T, stdout string) {
assert.Equal(t, "user", gjson.Get(stdout, "api.0.params.member_type").String(), "stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.params.limit").Exists(), "member-list no longer sends pagination; stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.params.offset").Exists(), "member-list no longer sends pagination; stdout:\n%s", stdout)
},
},
{
name: "RoleMemberAdd_MapsUsersDepartmentsChatsToBackendTypes",
args: []string{"apps", "+role-member-add", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--users", "ou_a,ou_b", "--departments", "od-a", "--chats", "oc_a", "--dry-run"},
wantMethod: "POST",
wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_analyst/member_add",
assertShape: func(t *testing.T, stdout string) {
assert.Equal(t, "ou_a", gjson.Get(stdout, "api.0.body.users.0").String(), "stdout:\n%s", stdout)
assert.Equal(t, "ou_b", gjson.Get(stdout, "api.0.body.users.1").String(), "stdout:\n%s", stdout)
assert.Equal(t, "od-a", gjson.Get(stdout, "api.0.body.departments.0").String(), "stdout:\n%s", stdout)
assert.Equal(t, "oc_a", gjson.Get(stdout, "api.0.body.chats.0").String(), "stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.body.members").Exists(), "API contract uses users/departments/chats arrays; stdout:\n%s", stdout)
},
},
{
name: "RoleMemberRemove_UsesGroupedBody",
args: []string{"apps", "+role-member-remove", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--users", "ou_a", "--yes", "--dry-run"},
wantMethod: "POST",
wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_analyst/member_remove",
assertShape: func(t *testing.T, stdout string) {
assert.Equal(t, "ou_a", gjson.Get(stdout, "api.0.body.users.0").String(), "stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.body.members").Exists(), "API contract uses users/departments/chats arrays; stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.body.all").Exists(), "all should be omitted when removing explicit members; stdout:\n%s", stdout)
},
},
{
name: "RoleMemberRemoveAll_SendsOnlyAllTrue",
args: []string{"apps", "+role-member-remove", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--all", "--yes", "--dry-run"},
wantMethod: "POST",
wantURL: "/open-apis/spark/v1/apps/app_role_e2e/roles/role_analyst/member_remove",
assertShape: func(t *testing.T, stdout string) {
assert.True(t, gjson.Get(stdout, "api.0.body.all").Bool(), "stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.body.members").Exists(), "--all body must not include explicit members; stdout:\n%s", stdout)
},
},
{
name: "RoleMatchList_UsesFullQueryEndpointWithoutRoleID",
args: []string{"apps", "+role-match-list", "--app-id", "app_role_e2e", "--user-id", "ou_target", "--dry-run"},
wantMethod: "POST",
wantURL: "/open-apis/spark/v1/apps/app_role_e2e/user_role_list",
assertShape: func(t *testing.T, stdout string) {
assert.Equal(t, "ou_target", gjson.Get(stdout, "api.0.body.target_user_id").String(), "stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.body.role_id").Exists(), "role-match-list must not require role_id; stdout:\n%s", stdout)
assert.False(t, gjson.Get(stdout, "api.0.params").Exists(), "role-match-list must use body instead of query params; stdout:\n%s", stdout)
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tc.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
dryRunData := gjson.Get(result.Stdout, "data").Raw
require.NotEmpty(t, dryRunData, "dry-run output must expose request data under data; stdout:\n%s", result.Stdout)
assert.Equal(t, tc.wantMethod, gjson.Get(dryRunData, "api.0.method").String(), "stdout:\n%s", result.Stdout)
assert.Equal(t, tc.wantURL, gjson.Get(dryRunData, "api.0.url").String(), "stdout:\n%s", result.Stdout)
tc.assertShape(t, dryRunData)
})
}
}
func TestAppsRoleManagementValidation(t *testing.T) {
setAppsRoleDryRunEnv(t)
tests := []struct {
name string
args []string
wantParam string
wantParams []string
wantMessage string
wantHint string
}{
{
name: "RejectsCreateWithoutNameWithStructuredParam",
args: []string{"apps", "+role-create", "--app-id", "app_role_e2e", "--description", "Can inspect BI reports", "--dry-run"},
wantParam: "--name",
wantMessage: "--name is required",
wantHint: "do not infer a name",
},
{
name: "RejectsInvalidRoleID",
args: []string{"apps", "+role-create", "--app-id", "app_role_e2e", "--role-id", "../bad", "--name", "Bad", "--dry-run"},
wantParam: "--role-id",
wantMessage: "--role-id must match [A-Za-z0-9_-]{1,64}",
},
{
name: "RejectsLarkCredentialAppID",
args: []string{"apps", "+role-list", "--app-id", "cli_role_e2e", "--dry-run"},
wantParam: "--app-id",
wantMessage: "Miaoda app_id",
},
{
name: "RejectsAppIDWithoutMiaodaPrefix",
args: []string{"apps", "+role-list", "--app-id", "plain_role_e2e", "--dry-run"},
wantParam: "--app-id",
wantMessage: "starting with app_",
},
{
name: "RejectsExplicitEmptyRoleListName",
args: []string{"apps", "+role-list", "--app-id", "app_role_e2e", "--name", "", "--dry-run"},
wantParam: "--name",
wantMessage: "--name must not be empty when provided",
},
{
name: "RejectsPageSizeAboveLimit",
args: []string{"apps", "+role-list", "--app-id", "app_role_e2e", "--page-size", "101", "--dry-run"},
wantParam: "--page-size",
wantMessage: "--page-size must be between 1 and 100",
},
{
name: "RejectsNonNumericPageToken",
args: []string{"apps", "+role-list", "--app-id", "app_role_e2e", "--page-token", "next", "--dry-run"},
wantParam: "--page-token",
wantMessage: "--page-token",
},
{
name: "RejectsUpdateWithoutPatchFields",
args: []string{"apps", "+role-update", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--dry-run"},
wantParams: []string{"--name", "--description"},
wantMessage: "--name or --description is required",
},
{
name: "RejectsMemberAddWithoutMembers",
args: []string{"apps", "+role-member-add", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--dry-run"},
wantParams: []string{"--users", "--departments", "--chats"},
wantMessage: "at least one of --users, --departments, or --chats is required",
},
{
name: "RejectsMoreThan100MembersBeforeDryRun",
args: []string{"apps", "+role-member-add", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--users", strings.TrimSuffix(strings.Repeat("ou_a,", 101), ","), "--dry-run"},
wantParams: []string{"--users"},
wantMessage: "role members cannot exceed 100",
},
{
name: "RejectsRemoveAllWithExplicitMembers",
args: []string{"apps", "+role-member-remove", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--all", "--users", "ou_a", "--yes", "--dry-run"},
wantParams: []string{"--all", "--users"},
wantMessage: "--all",
},
{
name: "RejectsRemoveWithoutMembersOrAll",
args: []string{"apps", "+role-member-remove", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--yes", "--dry-run"},
wantParams: []string{"--users", "--departments", "--chats", "--all"},
wantMessage: "specify members to remove",
},
{
name: "RejectsBlankUserIDForMatchList",
args: []string{"apps", "+role-match-list", "--app-id", "app_role_e2e", "--user-id", " ", "--dry-run"},
wantParam: "--user-id",
wantMessage: "user-id",
},
{
name: "RejectsEmailUserIDForMatchList",
args: []string{"apps", "+role-match-list", "--app-id", "app_role_e2e", "--user-id", "alice@example.com", "--dry-run"},
wantParam: "--user-id",
wantMessage: "ou_",
},
{
name: "RejectsWrongDepartmentIDPrefix",
args: []string{"apps", "+role-member-add", "--app-id", "app_role_e2e", "--role-id", "role_analyst", "--departments", "ou_user", "--dry-run"},
wantParam: "--departments",
wantMessage: "od-",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: tc.args,
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 2)
envelope := validationEnvelope(result)
assert.Equal(t, "validation", gjson.Get(envelope, "error.type").String(), "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
assert.Equal(t, "invalid_argument", gjson.Get(envelope, "error.subtype").String(), "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
if len(tc.wantParams) > 0 {
assert.False(t, gjson.Get(envelope, "error.param").Exists(), "scalar param must be omitted: %s", envelope)
gotParams := make([]string, 0, len(tc.wantParams))
for _, param := range gjson.Get(envelope, "error.params.#.name").Array() {
gotParams = append(gotParams, param.String())
}
assert.Equal(t, tc.wantParams, gotParams, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
} else {
assert.Equal(t, tc.wantParam, gjson.Get(envelope, "error.param").String(), "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
assert.Contains(t, envelope, tc.wantMessage, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
if tc.wantHint != "" {
assert.Contains(t, gjson.Get(envelope, "error.hint").String(), tc.wantHint, "stdout:\n%s\nstderr:\n%s", result.Stdout, result.Stderr)
}
})
}
}
func TestAppsRoleManagementLiveWorkflow(t *testing.T) {
requireLiveRoleFixture(t)
appID := os.Getenv("LARK_CLI_E2E_APPS_ROLE_APP_ID")
roleID := liveAppsRoleFixtureID()
member := requireLiveRoleMemberFixture(t)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
baselineResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"apps", "+role-member-list", "--app-id", appID, "--role-id", roleID, "--member-type", member.memberType},
DefaultAs: "user",
}, clie2e.RetryOptions{})
require.NoError(t, err)
baselineResult.AssertExitCode(t, 0)
baselineResult.AssertStdoutStatus(t, true)
if jsonStringArrayContains(baselineResult.Stdout, member.dataPath, member.id) {
t.Skipf("FIXTURE: member %s already belongs to role %s; refusing to mutate pre-existing state", member.id, roleID)
}
needsMemberCleanup := false
t.Cleanup(func() {
if !needsMemberCleanup {
return
}
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
removeResult, removeErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"apps", "+role-member-remove", "--app-id", appID, "--role-id", roleID, member.flag, member.id},
DefaultAs: "user",
Yes: true,
})
clie2e.ReportCleanupFailure(t, "remove added apps role member "+member.id, removeResult, removeErr)
})
t.Run("read fixture role", func(t *testing.T) {
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"apps", "+role-get", "--app-id", appID, "--role-id", roleID},
DefaultAs: "user",
}, clie2e.RetryOptions{})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
assert.Equal(t, roleID, gjson.Get(result.Stdout, "data.role.role_id").String(), "stdout:\n%s", result.Stdout)
})
t.Run("add list and remove fixture member", func(t *testing.T) {
// Arm cleanup before the write so a transport failure after a committed
// request cannot leak the member into the shared fixture role.
needsMemberCleanup = true
addResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+role-member-add", "--app-id", appID, "--role-id", roleID, member.flag, member.id},
DefaultAs: "user",
})
require.NoError(t, err)
addResult.AssertExitCode(t, 0)
addResult.AssertStdoutStatus(t, true)
assert.True(t, jsonStringArrayContains(addResult.Stdout, member.dataPath, member.id), "stdout:\n%s", addResult.Stdout)
listResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"apps", "+role-member-list", "--app-id", appID, "--role-id", roleID, "--member-type", member.memberType},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
return !jsonStringArrayContains(result.Stdout, member.dataPath, member.id)
},
})
require.NoError(t, err)
listResult.AssertExitCode(t, 0)
listResult.AssertStdoutStatus(t, true)
assert.True(t, jsonStringArrayContains(listResult.Stdout, member.dataPath, member.id), "stdout:\n%s", listResult.Stdout)
removeResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+role-member-remove", "--app-id", appID, "--role-id", roleID, member.flag, member.id},
DefaultAs: "user",
Yes: true,
})
require.NoError(t, err)
removeResult.AssertExitCode(t, 0)
removeResult.AssertStdoutStatus(t, true)
assert.True(t, jsonStringArrayContains(removeResult.Stdout, member.dataPath, member.id), "stdout:\n%s", removeResult.Stdout)
removedReadback, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"apps", "+role-member-list", "--app-id", appID, "--role-id", roleID, "--member-type", member.memberType},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || jsonStringArrayContains(result.Stdout, member.dataPath, member.id)
},
})
require.NoError(t, err)
removedReadback.AssertExitCode(t, 0)
removedReadback.AssertStdoutStatus(t, true)
require.False(t, jsonStringArrayContains(removedReadback.Stdout, member.dataPath, member.id), "stdout:\n%s", removedReadback.Stdout)
needsMemberCleanup = false
})
}
func TestAppsRoleLifecycleLiveWorkflow(t *testing.T) {
requireLiveRoleLifecycleFixture(t)
appID := strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_APP_ID"))
member := requireLiveRoleMemberFixture(t)
suffix := strings.ReplaceAll(clie2e.GenerateSuffix(), "-", "_")
roleID := "role_e2e_" + suffix
roleName := "CLI Role E2E " + suffix
createdDescription := "created by role lifecycle e2e"
updatedDescription := "updated by role lifecycle e2e"
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
t.Cleanup(cancel)
roleMayExist := true
t.Cleanup(func() {
if !roleMayExist {
return
}
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
listResult, listErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"apps", "+role-list", "--app-id", appID, "--name", roleName},
DefaultAs: "user",
})
if listErr != nil || listResult == nil || listResult.ExitCode != 0 {
clie2e.ReportCleanupFailure(t, "locate transient apps role "+roleID, listResult, listErr)
return
}
if !roleListContainsID(listResult.Stdout, roleID) {
return
}
clearResult, clearErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"apps", "+role-member-remove", "--app-id", appID, "--role-id", roleID, "--all"},
DefaultAs: "user",
Yes: true,
})
clie2e.ReportCleanupFailure(t, "clear transient apps role members "+roleID, clearResult, clearErr)
deleteResult, deleteErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"apps", "+role-delete", "--app-id", appID, "--role-id", roleID},
DefaultAs: "user",
Yes: true,
})
clie2e.ReportCleanupFailure(t, "delete transient apps role "+roleID, deleteResult, deleteErr)
})
createResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+role-create", "--app-id", appID, "--role-id", roleID,
"--name", roleName, "--description", createdDescription,
},
DefaultAs: "user",
})
require.NoError(t, err)
createResult.AssertExitCode(t, 0)
createResult.AssertStdoutStatus(t, true)
assert.Equal(t, roleID, gjson.Get(createResult.Stdout, "data.role.role_id").String(), "stdout:\n%s", createResult.Stdout)
createdReadback := readRoleUntil(t, ctx, appID, roleID, func(result *clie2e.Result) bool {
return gjson.Get(result.Stdout, "data.role.name").String() == roleName &&
gjson.Get(result.Stdout, "data.role.description").String() == createdDescription
})
assert.Equal(t, roleID, gjson.Get(createdReadback.Stdout, "data.role.role_id").String(), "stdout:\n%s", createdReadback.Stdout)
updateResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"apps", "+role-update", "--app-id", appID, "--role-id", roleID,
"--description", updatedDescription,
},
DefaultAs: "user",
})
require.NoError(t, err)
updateResult.AssertExitCode(t, 0)
updateResult.AssertStdoutStatus(t, true)
assert.Equal(t, roleID, gjson.Get(updateResult.Stdout, "data.role.role_id").String(), "stdout:\n%s", updateResult.Stdout)
readRoleUntil(t, ctx, appID, roleID, func(result *clie2e.Result) bool {
return gjson.Get(result.Stdout, "data.role.description").String() == updatedDescription
})
addResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+role-member-add", "--app-id", appID, "--role-id", roleID, member.flag, member.id},
DefaultAs: "user",
})
require.NoError(t, err)
addResult.AssertExitCode(t, 0)
addResult.AssertStdoutStatus(t, true)
membersResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"apps", "+role-member-list", "--app-id", appID, "--role-id", roleID},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || !jsonStringArrayContains(result.Stdout, member.dataPath, member.id)
},
})
require.NoError(t, err)
membersResult.AssertExitCode(t, 0)
membersResult.AssertStdoutStatus(t, true)
assert.True(t, jsonStringArrayContains(membersResult.Stdout, member.dataPath, member.id), "stdout:\n%s", membersResult.Stdout)
clearResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+role-member-remove", "--app-id", appID, "--role-id", roleID, "--all"},
DefaultAs: "user",
Yes: true,
})
require.NoError(t, err)
clearResult.AssertExitCode(t, 0)
clearResult.AssertStdoutStatus(t, true)
clearedReadback, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"apps", "+role-member-list", "--app-id", appID, "--role-id", roleID},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || !allRoleMemberGroupsEmpty(result.Stdout)
},
})
require.NoError(t, err)
clearedReadback.AssertExitCode(t, 0)
clearedReadback.AssertStdoutStatus(t, true)
assert.True(t, allRoleMemberGroupsEmpty(clearedReadback.Stdout), "stdout:\n%s", clearedReadback.Stdout)
readRoleUntil(t, ctx, appID, roleID, func(result *clie2e.Result) bool {
return gjson.Get(result.Stdout, "data.role.role_id").String() == roleID
})
deleteResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+role-delete", "--app-id", appID, "--role-id", roleID},
DefaultAs: "user",
Yes: true,
})
require.NoError(t, err)
deleteResult.AssertExitCode(t, 0)
deleteResult.AssertStdoutStatus(t, true)
assert.Equal(t, roleID, gjson.Get(deleteResult.Stdout, "data.role_id").String(), "stdout:\n%s", deleteResult.Stdout)
assert.True(t, gjson.Get(deleteResult.Stdout, "data.deleted").Bool(), "stdout:\n%s", deleteResult.Stdout)
absentResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"apps", "+role-list", "--app-id", appID, "--name", roleName},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || roleListContainsID(result.Stdout, roleID)
},
})
require.NoError(t, err)
absentResult.AssertExitCode(t, 0)
absentResult.AssertStdoutStatus(t, true)
require.False(t, roleListContainsID(absentResult.Stdout, roleID), "stdout:\n%s", absentResult.Stdout)
roleMayExist = false
}
func TestAppsRoleMatchListLiveWorkflow(t *testing.T) {
requireLiveRoleFixture(t)
if os.Getenv("LARK_CLI_E2E_APPS_ROLE_MATCH_READY") != "1" {
t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ROLE_MATCH_READY=1 when backend user_role_list is ready for live role-match-list proof")
}
appID := os.Getenv("LARK_CLI_E2E_APPS_ROLE_APP_ID")
userID := requireLiveRoleUserFixture(t)
roleID := liveAppsRoleFixtureID()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
t.Cleanup(cancel)
baselineResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"apps", "+role-member-list", "--app-id", appID, "--role-id", roleID, "--member-type", "user"},
DefaultAs: "user",
}, clie2e.RetryOptions{})
require.NoError(t, err)
baselineResult.AssertExitCode(t, 0)
baselineResult.AssertStdoutStatus(t, true)
if jsonStringArrayContains(baselineResult.Stdout, "data.users", userID) {
t.Skipf("FIXTURE: user %s already belongs to role %s; refusing to mutate pre-existing state", userID, roleID)
}
needsMemberCleanup := false
t.Cleanup(func() {
if !needsMemberCleanup {
return
}
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
removeResult, removeErr := clie2e.RunCmd(cleanupCtx, clie2e.Request{
Args: []string{"apps", "+role-member-remove", "--app-id", appID, "--role-id", roleID, "--users", userID},
DefaultAs: "user",
Yes: true,
})
clie2e.ReportCleanupFailure(t, "remove added apps role user "+userID, removeResult, removeErr)
})
// Arm cleanup before the write so a transport failure after a committed
// request cannot leak the user into the shared fixture role.
needsMemberCleanup = true
addResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{"apps", "+role-member-add", "--app-id", appID, "--role-id", roleID, "--users", userID},
DefaultAs: "user",
})
require.NoError(t, err)
addResult.AssertExitCode(t, 0)
addResult.AssertStdoutStatus(t, true)
matchResult, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"apps", "+role-match-list", "--app-id", appID, "--user-id", userID},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
if result == nil || result.ExitCode != 0 {
return true
}
return !gjson.Get(result.Stdout, `data.roles.#(role_id=="`+roleID+`")`).Exists()
},
})
require.NoError(t, err)
matchResult.AssertExitCode(t, 0)
matchResult.AssertStdoutStatus(t, true)
assert.True(t, gjson.Get(matchResult.Stdout, `data.roles.#(role_id=="`+roleID+`")`).Exists(), "stdout:\n%s", matchResult.Stdout)
}
func jsonStringArrayContains(raw, path, want string) bool {
for _, item := range gjson.Get(raw, path).Array() {
if item.String() == want {
return true
}
}
return false
}
func roleListContainsID(raw, roleID string) bool {
for _, item := range gjson.Get(raw, "data.items").Array() {
if item.Get("role_id").String() == roleID {
return true
}
}
return false
}
func allRoleMemberGroupsEmpty(raw string) bool {
for _, path := range []string{"data.users", "data.departments", "data.chats"} {
group := gjson.Get(raw, path)
if !group.Exists() || !group.IsArray() || len(group.Array()) != 0 {
return false
}
}
return true
}
func readRoleUntil(t *testing.T, ctx context.Context, appID, roleID string, ready func(*clie2e.Result) bool) *clie2e.Result {
t.Helper()
result, err := clie2e.RunCmdWithRetry(ctx, clie2e.Request{
Args: []string{"apps", "+role-get", "--app-id", appID, "--role-id", roleID},
DefaultAs: "user",
}, clie2e.RetryOptions{
ShouldRetry: func(result *clie2e.Result) bool {
return result == nil || result.ExitCode != 0 || !ready(result)
},
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
result.AssertStdoutStatus(t, true)
return result
}
func setAppsRoleDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "apps_role_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "apps_role_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
func validationEnvelope(result *clie2e.Result) string {
if result.Stdout != "" {
return result.Stdout
}
return result.Stderr
}
func requireLiveRoleFixture(t *testing.T) {
t.Helper()
if os.Getenv("LARKSUITE_CLI_CONFIG_DIR") == "" {
t.Skip("FIXTURE: Set LARKSUITE_CLI_CONFIG_DIR to an isolated test config such as $HOME/.lark-cli-test; this live workflow must not use the default human profile")
}
if os.Getenv("LARK_CLI_E2E_APPS_ROLE_APP_ID") == "" {
t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ROLE_APP_ID to a dedicated test app where the test user can create/update/delete roles")
}
if liveAppsRoleFixtureID() == "" {
t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ROLE_ID to the dedicated test role")
}
}
func requireLiveRoleLifecycleFixture(t *testing.T) {
t.Helper()
if os.Getenv("LARKSUITE_CLI_CONFIG_DIR") == "" {
t.Skip("FIXTURE: Set LARKSUITE_CLI_CONFIG_DIR to an isolated test config; lifecycle E2E must not use the default human profile")
}
if strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_APP_ID")) == "" {
t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ROLE_APP_ID to a dedicated test app where the test user can create/update/delete roles")
}
if strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_CHAT_OPEN_ID")) == "" &&
strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_USER_OPEN_ID")) == "" {
t.Skip("FIXTURE: Set a chat or user open ID for the transient role lifecycle member step")
}
}
func liveAppsRoleFixtureID() string {
return strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_ID"))
}
type liveRoleMemberFixture struct {
flag string
id string
memberType string
dataPath string
}
func requireLiveRoleMemberFixture(t *testing.T) liveRoleMemberFixture {
t.Helper()
if chatID := strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_CHAT_OPEN_ID")); chatID != "" {
return liveRoleMemberFixture{
flag: "--chats",
id: chatID,
memberType: "chat",
dataPath: "data.chats",
}
}
if userID := strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_USER_OPEN_ID")); userID != "" {
return liveRoleMemberFixture{
flag: "--users",
id: userID,
memberType: "user",
dataPath: "data.users",
}
}
t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ROLE_CHAT_OPEN_ID for chat-member live E2E, or LARK_CLI_E2E_APPS_ROLE_USER_OPEN_ID for user-member fallback")
return liveRoleMemberFixture{}
}
func requireLiveRoleUserFixture(t *testing.T) string {
t.Helper()
if userID := strings.TrimSpace(os.Getenv("LARK_CLI_E2E_APPS_ROLE_USER_OPEN_ID")); userID != "" {
return userID
}
t.Skip("FIXTURE: Set LARK_CLI_E2E_APPS_ROLE_USER_OPEN_ID for live +role-match-list proof")
return ""
}

View File

@@ -1,11 +1,11 @@
# Apps CLI E2E Coverage
## Metrics
- Denominator: 9 leaf commands (all user-visible shortcuts)
- Command coverage: 100% (9/9)
- API dry-run coverage: 100% (7/7 API-backed commands)
- Denominator: 18 leaf commands in the selected apps E2E coverage set (not all 79 apps shortcuts)
- Selected command coverage: 100% (18/18)
- API dry-run coverage: 100% (16/16 API-backed commands)
- Local E2E coverage: 100% (2/2 local-only commands)
- Live coverage: 0%
- Live coverage: tracked role workflows are intentionally fixture-gated and skipped by default CI. When run manually with dedicated fixtures, a transient-role lifecycle covers create/get/update, member add/list/`--all` clear, role-presence readback, delete, and target-ID absence readback; shared-fixture workflows separately cover explicit member removal and `+role-match-list`.
## Summary
- `TestAppsCreateDryRun`: happy path with `--app-type html`, all-fields shape, rejection paths (missing name, missing app-type, invalid app-type, legacy uppercase `HTML`). `--app-type` is a strict lowercase enum (`html`/`full_stack`); the CLI does not normalize case — legacy uppercase compatibility is a server concern.
@@ -17,8 +17,13 @@
- `TestAppsGitCredentialInitDryRun`: URL shape for issuing an app Git PAT; no body; `app_id` query metadata included.
- `TestAppsGitCredentialListLocalE2E`: local-only command scans every app storage directory and reports repository URL and status without exposing PAT or expiry details.
- `TestAppsGitCredentialRemoveLocalE2E`: local cleanup command removes app-scoped metadata under an isolated config dir.
- `TestAppsRoleManagementDryRun_RequestShapes`: role CRUD/member/match request shapes for all 9 role shortcuts. Request/response fields follow the API contract: `name`, `role_id`, `users`, `departments`, `chats`, `target_user_id`, and `roles`.
- `TestAppsRoleManagementValidation`: deterministic typed validation for invalid role ID, page bounds/token, missing update fields, missing member input, `--all` conflicts, and missing `--user-id`.
- `TestAppsRoleManagementLiveWorkflow`: fixture-gated live role/member workflow against the role provided by `LARK_CLI_E2E_APPS_ROLE_ID`. It refuses to run when the selected member already exists, mutates only that member, and removes only that member during cleanup; it never changes a shared role definition or clears unrelated members.
- `TestAppsRoleLifecycleLiveWorkflow`: creates a uniquely named transient role, independently reads it back, updates and re-reads it, adds a fixture member, clears all members and proves the role still exists, then deletes it and verifies the target `role_id` is absent. Cleanup is armed before creation and uses only environment-provided test identifiers.
- `TestAppsRoleMatchListLiveWorkflow`: separately fixture-gated live `+role-match-list` proof against the same isolated fixture role. It also requires the selected user to be absent at baseline and removes only the user it added.
Blocked: Live E2E intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Revisit when the server exposes `DELETE /apps/{appId}`.
Blocked: General app create live E2E is intentionally not implemented yet. Apps has no `+delete` endpoint (OAPI doc explicitly defers archive/delete), so a create-and-cleanup workflow would leak tenant state. Selected role read/member/match live flows intentionally remain fixture-gated and skipped by default because they mutate app role members.
## Command Table
@@ -33,3 +38,12 @@ Blocked: Live E2E intentionally not implemented yet. Apps has no `+delete` endpo
| ✓ | apps +git-credential-init | shortcut | apps_git_credential_dryrun_test.go::TestAppsGitCredentialInitDryRun | `--app-id`; dry-run `GET /open-apis/spark/v1/apps/{app_id}/git_info` | live blocked: issues short-lived repository PAT |
| ✓ | apps +git-credential-list | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialListLocalE2E | no `--app-id`; scans all local app storage directories and reports `app_id`, repository URL, and status without PAT or expiry | local E2E only: no dry-run API because command is local read only |
| ✓ | apps +git-credential-remove | shortcut | apps_git_credential_local_test.go::TestAppsGitCredentialRemoveLocalE2E | `--app-id`; deletes local metadata, keychain PAT, and Git config | local E2E only: no dry-run API because command is local cleanup only |
| ✓ | apps +role-list | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes | `--app-id`; `--name` -> `name`; `--page-size` -> `limit`; `--page-token` -> `offset` | live depends on a role-capable app fixture |
| ✓ | apps +role-get | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes | `GET /roles/:role_id`; no body/query | live covered by fixture-gated role workflow |
| ✓ | apps +role-create | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes; apps_role_management_test.go::TestAppsRoleLifecycleLiveWorkflow | `POST /roles`; required `name`; optional `role_id` | transient live role is independently read back |
| ✓ | apps +role-update | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes; apps_role_management_test.go::TestAppsRoleLifecycleLiveWorkflow | `PATCH /roles/:role_id`; only changed fields | transient live role update is independently read back |
| ✓ | apps +role-delete | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes; apps_role_management_test.go::TestAppsRoleLifecycleLiveWorkflow | `DELETE /roles/:role_id`; high-risk confirmation | live flow verifies the target `role_id` is absent after deletion |
| ✓ | apps +role-member-list | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes | `GET /member_list`; optional `member_type=user/department/chat`; no pagination | live covered by fixture-gated role workflow for a provided chat member when available, otherwise a user member |
| ✓ | apps +role-member-add | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes | `POST /member_add`; body `users/departments/chats` open_id arrays | live covered by fixture-gated role workflow for a provided chat member when available, otherwise a user member |
| ✓ | apps +role-member-remove | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes; apps_role_management_test.go::TestAppsRoleManagementLiveWorkflow; apps_role_management_test.go::TestAppsRoleLifecycleLiveWorkflow | `POST /member_remove`; body `users/departments/chats` open_id arrays or `all=true`; high-risk confirmation | explicit removal and `--all` both have fixture-gated live readback coverage |
| ✓ | apps +role-match-list | shortcut | apps_role_management_test.go::TestAppsRoleManagementDryRun_RequestShapes; apps_role_management_test.go::TestAppsRoleMatchListLiveWorkflow | `POST /user_role_list`; body `target_user_id`; no `role_id`; response field is `roles` per the API contract | automated live runs only when `LARK_CLI_E2E_APPS_ROLE_MATCH_READY=1`; it reuses the role provided by `LARK_CLI_E2E_APPS_ROLE_ID` instead of creating a transient role |

View File

@@ -40,6 +40,32 @@ func TestBaseDashboardBlockGetDataDryRun(t *testing.T) {
assert.Contains(t, output, `"base_token": "app_x"`)
}
func TestBaseDashboardBlockGetDataDryRun_IgnoresDashboardID(t *testing.T) {
setBaseDryRunConfigEnv(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"base", "+dashboard-block-get-data",
"--base-token", "app_x",
"--dashboard-id", "dsh_ignored",
"--block-id", "blk_chart",
"--dry-run",
},
BinaryPath: "../../../lark-cli",
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
output := strings.TrimSpace(result.Stdout)
assert.Contains(t, output, "/open-apis/base/v3/bases/app_x/dashboards/blocks/blk_chart/data")
assert.Contains(t, output, `"block_id": "blk_chart"`)
assert.NotContains(t, output, "dsh_ignored")
}
func TestBaseDashboardBlockGetDataDryRun_MissingRequiredFlags(t *testing.T) {
setBaseDryRunConfigEnv(t)

View File

@@ -2,8 +2,8 @@
## Metrics
- Denominator: 32 leaf commands
- Covered: 11
- Coverage: 34.4%
- Covered: 13
- Coverage: 40.6%
## 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,6 +16,7 @@
- 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.
@@ -29,7 +30,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 | | none | no primary delete workflow yet |
| | 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 +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 |
@@ -41,7 +42,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 | | none | no async task-result workflow yet |
| | 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 +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

@@ -0,0 +1,59 @@
// 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

@@ -0,0 +1,94 @@
// 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 accepted delete: %w", docType, token, err),
fmt.Errorf("drive resource %s/%s still visible after async delete: %w", docType, token, err),
)
}
return deleteResult, nil

View File

@@ -0,0 +1,93 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func setWikiMoveToDriveDryRunEnv(t *testing.T) {
t.Helper()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_APP_ID", "wiki_move_to_drive_dryrun_test")
t.Setenv("LARKSUITE_CLI_APP_SECRET", "wiki_move_to_drive_dryrun_secret")
t.Setenv("LARKSUITE_CLI_BRAND", "feishu")
}
// TestWikiMoveToDriveDryRun pins both requests in the async orchestration
// without requiring credentials or calling a real tenant.
func TestWikiMoveToDriveDryRun(t *testing.T) {
setWikiMoveToDriveDryRunEnv(t)
t.Run("target folder", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+move-to-drive",
"--node-token", "wikcnABC123",
"--folder-token", "fldABC123",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/wiki/v2/nodes/wikcnABC123/move_wiki_to_docs", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.Equal(t, "fldABC123", clie2e.DryRunGet(result.Stdout, "api.0.body.folder_token").String())
assert.Equal(t, "GET", clie2e.DryRunGet(result.Stdout, "api.1.method").String())
assert.Equal(t, "/open-apis/wiki/v2/tasks/%3Ctask_id%3E", clie2e.DryRunGet(result.Stdout, "api.1.url").String())
assert.Equal(t, "move_wiki_to_docs", clie2e.DryRunGet(result.Stdout, "api.1.params.task_type").String())
})
t.Run("personal space root", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+move-to-drive",
"--node-token", "wikcnABC123",
"--dry-run",
},
DefaultAs: "user",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "POST", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.False(t, clie2e.DryRunGet(result.Stdout, "api.0.body.folder_token").Exists(),
"folder_token must be omitted when targeting personal-space root; stdout:\n%s", result.Stdout)
})
t.Run("standalone continuation query", func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
t.Cleanup(cancel)
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+task_result",
"--scenario", "wiki_move_to_drive",
"--task-id", "task-raw-signature",
"--dry-run",
},
DefaultAs: "bot",
})
require.NoError(t, err)
result.AssertExitCode(t, 0)
assert.Equal(t, "GET", clie2e.DryRunGet(result.Stdout, "api.0.method").String())
assert.Equal(t, "/open-apis/wiki/v2/tasks/task-raw-signature", clie2e.DryRunGet(result.Stdout, "api.0.url").String())
assert.Equal(t, "move_wiki_to_docs", clie2e.DryRunGet(result.Stdout, "api.0.params.task_type").String())
})
}

View File

@@ -0,0 +1,309 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"fmt"
"testing"
"time"
clie2e "github.com/larksuite/cli/tests/cli_e2e"
drivee2e "github.com/larksuite/cli/tests/cli_e2e/drive"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// TestWiki_MoveToDriveWorkflow validates the live async round trip, including
// the standalone drive +task_result continuation path.
func TestWiki_MoveToDriveWorkflow(t *testing.T) {
parentT := t
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Minute)
t.Cleanup(cancel)
suffix := clie2e.GenerateSuffix()
nodeTitle := "lark-cli-e2e-wiki-to-drive-node-" + suffix
folderToken := drivee2e.CreateDriveFolder(
t,
parentT,
ctx,
"lark-cli-e2e-wiki-to-drive-"+suffix,
"bot",
"",
)
_, node := createWikiNodeUnderAnyHost(
t,
parentT,
ctx,
nodeTitle,
)
nodeToken := node.Get("node_token").String()
require.NotEmpty(t, nodeToken)
var moveTaskID, movedObjToken, movedObjType string
// Register fallback cleanup before creating the async task. If the command
// times out or its success payload omits optional resource fields, find the
// uniquely named document in the target folder so the folder cleanup does
// not leak a non-empty tree.
parentT.Cleanup(func() {
cleanupCtx, cleanupCancel := clie2e.CleanupContext()
defer cleanupCancel()
targets := []wikiMoveToDriveResource{}
if movedObjToken != "" {
objType := movedObjType
if objType == "" {
objType = "docx"
}
targets = append(targets, wikiMoveToDriveResource{Token: movedObjToken, Type: objType})
} else {
listed, listResult, listErr := waitForWikiMoveToDriveResources(
cleanupCtx,
folderToken,
nodeTitle,
moveTaskID,
)
if listErr != nil {
clie2e.ReportCleanupFailure(parentT, "list wiki move-to-drive cleanup targets", listResult, listErr)
return
}
targets = listed
}
for _, target := range targets {
deleteResult, deleteErr := drivee2e.DeleteDriveResourceAndVerify(cleanupCtx, target.Token, target.Type, "bot")
clie2e.ReportCleanupFailure(parentT, "delete moved Drive document "+target.Token, deleteResult, deleteErr)
}
})
moveResult, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"wiki", "+move-to-drive",
"--node-token", nodeToken,
"--folder-token", folderToken,
},
DefaultAs: "bot",
})
if moveResult != nil {
moveTaskID = gjson.Get(moveResult.Stdout, "data.task_id").String()
}
require.NoError(t, err)
moveResult.AssertExitCode(t, 0)
moveResult.AssertStdoutStatus(t, true)
moveData := waitWikiMoveToDriveReady(t, ctx, moveResult)
movedObjToken = moveData.Get("data.obj_token").String()
movedObjType = moveData.Get("data.obj_type").String()
require.NotEmpty(t, movedObjToken, "move result must contain obj_token; stdout:\n%s", moveData.Raw)
require.NotEmpty(t, movedObjType, "move result must contain obj_type; stdout:\n%s", moveData.Raw)
require.NotEmpty(t, moveData.Get("data.url").String(), "move result must contain url; stdout:\n%s", moveData.Raw)
err = waitWikiNodeDeleted(ctx, nodeToken)
require.NoError(t, err, "source wiki node should disappear after move-to-drive")
err = clie2e.WaitForCondition(ctx, clie2e.WaitOptions{
Timeout: 45 * time.Second,
Interval: 3 * time.Second,
TimeoutError: func() error {
return fmt.Errorf("moved document %s did not appear in target folder %s", movedObjToken, folderToken)
},
}, func() (bool, error) {
listResult, listErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "files", "list",
"--folder-token", folderToken,
"--page-size", "200",
},
DefaultAs: "bot",
})
if listErr != nil {
return false, listErr
}
if listResult.ExitCode != 0 {
return false, fmt.Errorf(
"list target folder failed: exit=%d stdout=%s stderr=%s",
listResult.ExitCode,
listResult.Stdout,
listResult.Stderr,
)
}
match := gjson.Get(listResult.Stdout, `data.files.#(token=="`+movedObjToken+`")`)
return match.Exists() && match.Get("type").String() == movedObjType, nil
})
require.NoError(t, err)
}
type wikiMoveToDriveResource struct {
Token string
Type string
}
func waitForWikiMoveToDriveResources(
ctx context.Context,
folderToken string,
name string,
taskID string,
) ([]wikiMoveToDriveResource, *clie2e.Result, error) {
const discoveryTimeout = 20 * time.Second
discoveryCtx, cancel := context.WithTimeout(ctx, discoveryTimeout)
defer cancel()
var resources []wikiMoveToDriveResource
var lastResult *clie2e.Result
var lastErr error
err := clie2e.WaitForCondition(discoveryCtx, clie2e.WaitOptions{
Timeout: discoveryTimeout,
Interval: 2 * time.Second,
TimeoutError: func() error {
if lastErr != nil {
return fmt.Errorf("move-to-drive cleanup target did not become visible within %s: %w", discoveryTimeout, lastErr)
}
return fmt.Errorf("move-to-drive cleanup target did not become visible within %s", discoveryTimeout)
},
}, func() (bool, error) {
if taskID != "" {
taskResult, taskErr := clie2e.RunCmd(discoveryCtx, clie2e.Request{
Args: []string{
"drive", "+task_result",
"--scenario", "wiki_move_to_drive",
"--task-id", taskID,
},
DefaultAs: "bot",
})
if taskResult != nil {
lastResult = taskResult
}
if taskErr == nil && taskResult != nil && taskResult.ExitCode == 0 {
parsed := gjson.Parse(taskResult.Stdout)
if parsed.Get("data.failed").Bool() {
return true, nil
}
if parsed.Get("data.ready").Bool() {
resource := wikiMoveToDriveResource{
Token: parsed.Get("data.obj_token").String(),
Type: parsed.Get("data.obj_type").String(),
}
if resource.Token != "" && resource.Type != "" {
resources = []wikiMoveToDriveResource{resource}
return true, nil
}
}
} else if taskErr != nil {
lastErr = taskErr
}
}
listed, listResult, listErr := findWikiMoveToDriveResources(discoveryCtx, folderToken, name)
if listResult != nil {
lastResult = listResult
}
if listErr != nil {
lastErr = listErr
return false, nil //nolint:nilerr // retry transient cleanup discovery errors until the bounded timeout
}
if len(listed) == 0 {
return false, nil
}
resources = listed
return true, nil
})
return resources, lastResult, err
}
func findWikiMoveToDriveResources(
ctx context.Context,
folderToken string,
name string,
) ([]wikiMoveToDriveResource, *clie2e.Result, error) {
result, err := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "files", "list",
"--folder-token", folderToken,
"--page-size", "200",
},
DefaultAs: "bot",
})
if err != nil {
return nil, result, err
}
if result.ExitCode != 0 {
return nil, result, fmt.Errorf(
"list target folder failed: exit=%d stdout=%s stderr=%s",
result.ExitCode,
result.Stdout,
result.Stderr,
)
}
resources := []wikiMoveToDriveResource{}
gjson.Get(result.Stdout, "data.files").ForEach(func(_, entry gjson.Result) bool {
if entry.Get("name").String() != name {
return true
}
resource := wikiMoveToDriveResource{
Token: entry.Get("token").String(),
Type: entry.Get("type").String(),
}
if resource.Token != "" && resource.Type != "" {
resources = append(resources, resource)
}
return true
})
return resources, result, nil
}
func waitWikiMoveToDriveReady(t *testing.T, ctx context.Context, initial *clie2e.Result) gjson.Result {
t.Helper()
current := gjson.Parse(initial.Stdout)
taskID := current.Get("data.task_id").String()
require.NotEmpty(t, taskID, "async move result must contain task_id; stdout:\n%s", initial.Stdout)
queryTask := func() (bool, error) {
result, runErr := clie2e.RunCmd(ctx, clie2e.Request{
Args: []string{
"drive", "+task_result",
"--scenario", "wiki_move_to_drive",
"--task-id", taskID,
},
DefaultAs: "bot",
})
if runErr != nil {
return false, runErr
}
if result.ExitCode != 0 {
return false, fmt.Errorf(
"query wiki move-to-drive task failed: exit=%d stdout=%s stderr=%s",
result.ExitCode,
result.Stdout,
result.Stderr,
)
}
current = gjson.Parse(result.Stdout)
if current.Get("data.failed").Bool() {
return false, fmt.Errorf(
"wiki move-to-drive task %s failed: %s",
taskID,
current.Get("data.status_msg").String(),
)
}
return current.Get("data.ready").Bool(), nil
}
ready, err := queryTask()
require.NoError(t, err)
if ready {
return current
}
err = clie2e.WaitForCondition(ctx, clie2e.WaitOptions{
Timeout: 90 * time.Second,
Interval: 3 * time.Second,
TimeoutError: func() error {
return fmt.Errorf("wiki move-to-drive task %s did not finish", taskID)
},
}, queryTask)
require.NoError(t, err)
return current
}

View File

@@ -0,0 +1,265 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/tidwall/gjson"
)
// seededCatalogVersion is far newer than the embedded stub's 0.0.0, so the
// runtime overlay in internal/registry unconditionally applies it.
const seededCatalogVersion = "9.9.9"
// seededCatalogJSON is a remote_meta.json (registry.MergedRegistry) carrying one
// obviously-synthetic service. Seeding it into a bare-module fork's on-disk cache
// gives the runtime catalog real data WITHOUT any network, so a test can prove
// SchemaCatalog() consults that runtime catalog (issue #1764) rather than the
// embedded-only (empty stub) catalog. Fields mirror internal/meta.Service.
const seededCatalogJSON = `{
"version": "9.9.9",
"services": [
{
"name": "plugine2e",
"version": "v1",
"title": "plugin_e2e synthetic service",
"description": "synthetic fixture for the runtime-catalog test; not a real API",
"servicePath": "/open-apis/plugine2e/v1",
"resources": {
"widgets": {
"methods": {
"get": {
"id": "plugine2e.widgets.get",
"path": "/open-apis/plugine2e/v1/widgets/:id",
"httpMethod": "GET",
"description": "synthetic read method",
"risk": "read",
"accessTokens": ["tenant"],
"parameters": {
"id": {"type": "string", "location": "path", "required": true, "description": "synthetic id"}
}
}
}
}
}
}
]
}`
// runWithSeededCatalog runs bin against a fresh LARKSUITE_CLI_CONFIG_DIR whose
// cache already holds cacheJSON as remote_meta.json (plus a fresh, high-version
// cache-meta so the overlay applies and the TTL never triggers a refetch). Remote
// meta is left ON so the on-disk cache overlay is consulted, but a long
// LARKSUITE_CLI_META_TTL keeps the run offline and deterministic. This models a
// bare-module binary that has runtime metadata available from a warm cache.
func runWithSeededCatalog(t *testing.T, bin, cacheJSON string, args ...string) result {
t.Helper()
cfg := t.TempDir()
cacheDir := filepath.Join(cfg, "cache")
if err := os.MkdirAll(cacheDir, 0o755); err != nil {
t.Fatalf("mkdir cache dir: %v", err)
}
writeFile(t, filepath.Join(cacheDir, "remote_meta.json"), cacheJSON)
writeFile(t, filepath.Join(cacheDir, "remote_meta.meta.json"),
fmt.Sprintf(`{"last_check_at":%d,"version":%q,"brand":""}`, time.Now().Unix(), seededCatalogVersion))
env := append(baseEnv(),
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
"LARKSUITE_CLI_CONFIG_DIR="+cfg,
"LARKSUITE_CLI_META_TTL=1000000",
)
return runWithEnv(t, bin, env, args...)
}
// plainPlugin registers a minimal observer-only plugin with NO Restrict rule
// -- unlike readonly_test.go's plugins, it cannot deny "schema" as
// out-of-domain, so any failure the command produces below is the command's
// own behavior against the empty stub catalog, not a policy denial.
const plainPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("plain", "0.1.0").
Observer(platform.After, "noop", platform.All(),
func(_ context.Context, _ platform.Invocation) {}).
FailOpen().
MustBuild())
}
`
// TestDegradeStubMetadataSchema pins the #1764 stub-metadata degrade path.
// The clean tree embeds only the empty meta_data_default.json stub
// (internal/registry/catalog.go's SchemaCatalog falls through to
// RuntimeCatalog when EmbeddedServicesTyped() is empty), and run()'s isolated
// environment disables the remote overlay fetch and points the cache dir at
// an empty tmp dir, so cmd/schema/schema.go's runSchema sees
// catalog.Services() == 0 unconditionally -- the exact "offline with a cold
// cache, remote meta off" branch documented at cmd/schema/schema.go:96-101.
//
// Observed real output for both `schema` and `schema im.messages.reply`
// (identical -- runSchema checks catalog.Services()==0 before parsing args):
//
// exit=2
// stdout=(empty)
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"No API metadata available",
// "hint":"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"}}
//
// This is the PINNED "graceful degrade" criterion: a structured JSON envelope
// (gjson.Valid, no "panic:" substring) carrying a validation/failed_precondition
// error with an actionable hint, NOT the raw Go panic crash that
// install_test.go's TestInstallMustBuildInitPanicCrashesBinary pins for a
// genuinely broken plugin, and NOT an "Unknown"-shaped internal error.
// Note: exit==2 alone does not prove "not a crash" -- a genuine Go panic also
// exits 2. The two real discriminators against a crash are the absence of a
// "panic:" substring in stderr and stderr being valid JSON (gjson.Valid); both
// are asserted below.
func TestDegradeStubMetadataSchema(t *testing.T) {
bin := buildFork(t, "plain", plainPlugin)
cases := []struct {
name string
args []string
}{
{"schema root", []string{"schema"}},
{"schema with path", []string{"schema", "im.messages.reply"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := run(t, bin, tc.args...)
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
if res.exit != 2 {
t.Fatalf("exit=%d want 2 (graceful validation exit); stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if strings.Contains(res.stderr, "panic:") {
t.Fatalf("stderr contains a raw Go panic trace, not a graceful degrade; stderr=%s", res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not a structured JSON envelope: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); msg != "No API metadata available" {
t.Errorf("error.message=%q want %q", msg, "No API metadata available")
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, "no embedded API metadata") {
t.Errorf("error.hint=%q want to contain %q", hint, "no embedded API metadata")
}
})
}
}
// TestRuntimeCatalogResolvesSchema pins the PRIMARY #1764 fix: a bare-module fork
// (embedded stub only) resolves `schema` against the RUNTIME catalog seeded from
// the on-disk cache, not the embedded-only catalog. Before f0b6f35f the module
// build read the embedded-only catalog and returned "Unknown service: <svc>" even
// though the runtime registry had metadata; after it, registry.SchemaCatalog()
// falls back to the merged runtime catalog and the lookup succeeds.
//
// This is the counterpart to TestDegradeStubMetadataSchema: that test pins the
// cold-cache corner (no runtime data -> graceful "No API metadata available");
// this one pins the warm-cache main path (runtime data present -> schema works),
// so a regression that re-embeds the embedded-only lookup fails HERE with
// "Unknown service" rather than silently passing.
func TestRuntimeCatalogResolvesSchema(t *testing.T) {
bin := buildFork(t, "plain", plainPlugin)
res := runWithSeededCatalog(t, bin, seededCatalogJSON, "schema", "plugine2e")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
out := res.stdout + res.stderr
if strings.Contains(out, "Unknown service") {
t.Fatalf("schema returned \"Unknown service\" -> runtime catalog NOT consulted (issue #1764 regression); out=%s", out)
}
if strings.Contains(out, "No API metadata available") {
t.Fatalf("schema saw no metadata -> the seeded runtime cache was not loaded; out=%s", out)
}
if res.exit != 0 {
t.Fatalf("exit=%d want 0 (schema resolved from runtime catalog); stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !strings.Contains(out, "plugine2e") {
t.Errorf("schema output does not mention the seeded service; out=%s", out)
}
}
// credentialBlockPlugin registers a credential.Provider whose ResolveAccount
// (and ResolveToken) unconditionally return a *credential.BlockError.
// internal/credential/credential_provider.go's doResolveAccount returns this
// error straight from the provider loop -- before any defaultAcct fallback
// and, transitively, before the LarkClient/HttpClient phases that would issue
// a real network call ever run (see internal/cmdutil/factory_default.go's
// Phase 2 -> Phase 4 ordering).
const credentialBlockPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/credential"
)
type blockProvider struct{}
func (blockProvider) Name() string { return "block-cred" }
func (blockProvider) ResolveAccount(ctx context.Context) (*credential.Account, error) {
return nil, &credential.BlockError{Provider: "block-cred", Reason: "blocked for test"}
}
func (blockProvider) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.Token, error) {
return nil, &credential.BlockError{Provider: "block-cred", Reason: "blocked for test"}
}
func init() {
credential.Register(blockProvider{})
}
`
// TestSubsystemCredentialBlock pins the credential.BlockError offline effect.
// Observed real output for `docs +fetch --doc nonexistent`, run twice across
// separate `go test -count` invocations (byte-identical both times, unlike
// the transport-abort case -- credential resolution happens once, before any
// endpoint is chosen, so there is no varying destination URL to leak into the
// message):
//
// exit=5
// stdout=(empty)
// stderr={"ok":false,"identity":"bot","error":{"type":"internal","subtype":"unknown",
// "message":"blocked by block-cred: blocked for test"}}
func TestSubsystemCredentialBlock(t *testing.T) {
bin := buildFork(t, "credential-block", credentialBlockPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
if res.exit != 5 {
t.Fatalf("exit=%d want 5; stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "internal" {
t.Errorf("error.type=%q want internal", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "unknown" {
t.Errorf("error.subtype=%q want unknown", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); msg != "blocked by block-cred: blocked for test" {
t.Errorf("error.message=%q want %q", msg, "blocked by block-cred: blocked for test")
}
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"testing"
"github.com/tidwall/gjson"
)
// TestDiagnostics asserts the VERIFIED stdout shapes of the two policy/plugin
// diagnostic commands on a fork carrying the readonly Restrict rule:
// - `config policy show`: source_name == the plugin name that installed the
// active rule.
// - `config plugins show`: {"plugins":[{"name","version","capabilities",...,
// "hooks":{...}}],"total":N} with the readonly plugin present.
func TestDiagnostics(t *testing.T) {
bin := buildFork(t, "readonly", readonlyPlugin)
pol := run(t, bin, "config", "policy", "show")
if pol.exit != 0 || !gjson.Valid(pol.stdout) {
t.Fatalf("policy show exit=%d stdout=%s stderr=%s", pol.exit, pol.stdout, pol.stderr)
}
if src := gjson.Get(pol.stdout, "source_name").String(); src != "readonly" {
t.Errorf("policy source_name=%q want readonly (stdout=%s)", src, pol.stdout)
}
plug := run(t, bin, "config", "plugins", "show")
if plug.exit != 0 || !gjson.Valid(plug.stdout) {
t.Fatalf("plugins show exit=%d stdout=%s", plug.exit, plug.stdout)
}
if total := gjson.Get(plug.stdout, "total").Int(); total < 1 {
t.Errorf("plugins total=%d want >=1 (stdout=%s)", total, plug.stdout)
}
if name := gjson.Get(plug.stdout, "plugins.0.name").String(); name != "readonly" {
t.Errorf("plugins.0.name=%q want readonly", name)
}
}

246
tests/plugin_e2e/harness.go Normal file
View File

@@ -0,0 +1,246 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package plugin_e2e exercises the extension/platform plugin contract the way a
// real customer does: it builds a fork of lark-cli with a plugin blank-imported,
// then runs that fork as a subprocess and asserts the real stderr/stdout
// envelopes and exit codes. This is L4 coverage — the in-process unit and
// integration tests (extension/..., cmd/...) assert Go error values in the test
// process and structurally cannot observe envelope serialization, exit codes, or
// the blank-import -> init -> Register -> InstallAll assembly chain.
//
// Mechanism (the "customer build", mirrors xcaddy's build mode):
// 1. `git archive HEAD` a clean tree containing only committed files (so the
// fork embeds the tracked meta_data stub, reproducing the bare-module state).
// 2. Generate a customer module: go.mod (cli's requires + `replace` to the
// archived tree) + go.sum copy + main.go (blank-imports the plugin package)
// + plugin package (its init() calls platform.Register).
// 3. `go build` the fork (offline-capable via the warm module cache), then run
// it as a subprocess and assert.
package plugin_e2e
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// cleanTree is the git-archived, committed-only source tree of the repo under
// test, shared by every fork build. Populated by TestMain (smoke_test.go) —
// TestMain must live in a _test.go file to be recognized by `go test`, so the
// entry point sits there while the rest of the harness mechanism lives here.
var cleanTree string
// baseDir holds the archive tree plus every generated customer module.
var baseDir string
// repoRoot resolves the lark-cli module root from the test's working directory
// (which `go test` sets to the package dir, tests/plugin_e2e).
func repoRoot() (string, error) {
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
// gitArchive extracts HEAD's committed tree into dst by streaming `git archive`
// into `tar -x`. Only tracked files are included — gitignored build artifacts
// (e.g. the fetched meta_data.json) are absent, exactly as a module consumer
// would see them. It wires the two processes with an explicit pipe rather than a
// shell, so dst never reaches a shell command line.
func gitArchive(root, dst string) error {
archive := exec.Command("git", "archive", "HEAD")
archive.Dir = root
extract := exec.Command("tar", "-x", "-C", dst)
pipe, err := archive.StdoutPipe()
if err != nil {
return err
}
extract.Stdin = pipe
// Each process gets its own stderr buffer: os/exec spawns a copy goroutine
// per command, so a shared strings.Builder would be written concurrently by
// both (git archive and tar run in parallel) -- a data race, since
// strings.Builder is not concurrency-safe.
var archiveErr, extractErr strings.Builder
archive.Stderr = &archiveErr
extract.Stderr = &extractErr
if err := extract.Start(); err != nil {
return err
}
if err := archive.Run(); err != nil {
_ = extract.Wait()
return fmt.Errorf("git archive: %w: %s", err, archiveErr.String())
}
if err := extract.Wait(); err != nil {
return fmt.Errorf("tar extract: %w: %s", err, extractErr.String())
}
return nil
}
// builtForks caches fork binaries by name so identical forks are built once.
// builtForksMu guards it: no test in this package uses t.Parallel() today, but
// that is an implicit convention a future test could silently break, and an
// unguarded map write would then be a runtime panic. The lock is held across
// the whole build so concurrent callers also dedupe instead of racing to build
// the same fork twice.
var (
builtForksMu sync.Mutex
builtForks = map[string]string{}
)
// buildFork generates a customer module whose plugin package body is pluginSrc,
// builds the fork, and returns the binary path. Forks are cached by name.
func buildFork(t *testing.T, name, pluginSrc string) string {
t.Helper()
builtForksMu.Lock()
defer builtForksMu.Unlock()
if bin, ok := builtForks[name]; ok {
return bin
}
mod := filepath.Join(baseDir, "fork-"+name)
if err := os.MkdirAll(filepath.Join(mod, "plugin"), 0o755); err != nil {
t.Fatalf("mkdir customer module: %v", err)
}
// go.mod: reuse cli's require graph, rename the module, replace cli with the
// local archived tree. This avoids `go mod tidy` (no network at test time).
rawMod, err := os.ReadFile(filepath.Join(cleanTree, "go.mod"))
if err != nil {
t.Fatalf("read archived go.mod: %v", err)
}
gomod := strings.Replace(string(rawMod), "module github.com/larksuite/cli", "module larkcustomer", 1)
gomod += "\nrequire github.com/larksuite/cli v0.0.0\n\nreplace github.com/larksuite/cli => " + cleanTree + "\n"
writeFile(t, filepath.Join(mod, "go.mod"), gomod)
// go.sum: transitive dependency hashes are identical to cli's.
rawSum, err := os.ReadFile(filepath.Join(cleanTree, "go.sum"))
if err != nil {
t.Fatalf("read archived go.sum: %v", err)
}
writeFile(t, filepath.Join(mod, "go.sum"), string(rawSum))
writeFile(t, filepath.Join(mod, "main.go"), customerMain)
writeFile(t, filepath.Join(mod, "plugin", "plugin.go"), pluginSrc)
bin := filepath.Join(mod, "fork-bin")
build := exec.Command("go", "build", "-o", bin, ".")
build.Dir = mod
// -mod=mod fixes require annotations copied from cli's go.mod; the default
// GOPROXY resolves any dep missing from the cache (goproxy in CI/dev).
build.Env = append(os.Environ(), "GOFLAGS=-mod=mod")
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build fork %q failed: %v\n%s", name, err, out)
}
builtForks[name] = bin
return bin
}
const customerMain = `// Code generated by plugin_e2e; DO NOT EDIT.
package main
import (
"os"
"github.com/larksuite/cli/cmd"
_ "larkcustomer/plugin" // blank import triggers plugin init() -> platform.Register
)
func main() { os.Exit(cmd.Execute()) }
`
// result is a subprocess run outcome.
type result struct {
stdout string
stderr string
exit int
}
// run executes the fork binary with args in an isolated, offline environment and
// captures stdout/stderr/exit. Each call gets a fresh empty
// LARKSUITE_CLI_CONFIG_DIR and LARKSUITE_CLI_REMOTE_META=off, so the fork never
// inherits the host's ~/.lark-cli cache or makes a startup metadata fetch to the
// open platform. That reproduces the bare-module customer state (no embedded
// metadata, cold cache) deterministically on any machine, including CI: without
// it, whether a command's assertion is reached depends on whether a live network
// fetch happened to succeed. Tests that need runtime metadata seed it explicitly
// via runWithSeededCatalog.
func run(t *testing.T, bin string, args ...string) result {
t.Helper()
return runWithEnv(t, bin, isolatedEnv(t), args...)
}
// baseEnv is the host environment with every LARKSUITE_CLI_* variable removed.
// Appending overrides to a raw os.Environ() only isolates the variables we
// explicitly set — a developer machine exporting, say, LARKSUITE_CLI_AUTH_PROXY
// or LARKSUITE_CLI_BRAND would leak them into the fork (the transport
// interceptor and credential providers read them via os.Getenv directly),
// breaking the "deterministic on any machine" guarantee. Stripping the whole
// namespace first makes the fork's CLI-facing environment exactly the
// variables the harness sets, everywhere.
func baseEnv() []string {
env := os.Environ()
kept := env[:0]
for _, kv := range env {
if !strings.HasPrefix(kv, "LARKSUITE_CLI_") {
kept = append(kept, kv)
}
}
return kept
}
// isolatedEnv is the bare-module, offline environment shared by run() and (as a
// base) by runWithSeededCatalog.
func isolatedEnv(t *testing.T) []string {
t.Helper()
return append(baseEnv(),
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
"LARKSUITE_CLI_CONFIG_DIR="+t.TempDir(),
"LARKSUITE_CLI_REMOTE_META=off",
)
}
// runWithEnv runs bin as a subprocess with the given full environment, capturing
// stdout/stderr/exit.
func runWithEnv(t *testing.T, bin string, env []string, args ...string) result {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
c := exec.CommandContext(ctx, bin, args...)
c.Env = env
var stdout, stderr strings.Builder
c.Stdout = &stdout
c.Stderr = &stderr
err := c.Run()
// A fork that hangs is killed by the context and surfaces as a generic
// exit=-1 ExitError; name the timeout explicitly so the failure reads as
// "hung" rather than "crashed".
if ctx.Err() == context.DeadlineExceeded {
t.Fatalf("run %v: timed out after 60s; stdout=%s stderr=%s", args, stdout.String(), stderr.String())
}
exit := 0
if err != nil {
var ee *exec.ExitError
if errors.As(err, &ee) {
exit = ee.ExitCode()
} else {
t.Fatalf("run %v: %v", args, err)
}
}
return result{stdout: stdout.String(), stderr: stderr.String(), exit: exit}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}

View File

@@ -0,0 +1,339 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"strings"
"testing"
"github.com/tidwall/gjson"
)
// multipleRestrictPlugin registers TWO distinct plugins that each call
// Restrict() with an independently valid Rule. cmdpolicy.Resolve rejects
// more than one distinct Restrict-owner regardless of each rule's own
// validity (internal/cmdpolicy/resolver.go's distinctOwners check runs
// before ValidateRule).
const multipleRestrictPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("restrict-a", "0.1.0").
Restrict(&platform.Rule{
Name: "a-rule",
Allow: []string{"docs/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
platform.Register(
platform.NewPlugin("restrict-b", "0.1.0").
Restrict(&platform.Rule{
Name: "b-rule",
Allow: []string{"im/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// TestInstallMultipleRestrictPluginsPin pins reason_code=multiple_restrict_plugins.
// Observed real output (any command, e.g. "schema" -- the fatal guard walks
// every RunE in the tree so it fires regardless of which command runs):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"multiple plugins called Restrict; only one plugin may own the
// policy: [restrict-a restrict-b]",
// "hint":"plugin policy configuration is broken (reason_code
// multiple_restrict_plugins); fix the plugin's Restrict rule or remove the
// conflicting plugin"}}
func TestInstallMultipleRestrictPluginsPin(t *testing.T) {
bin := buildFork(t, "multiple-restrict", multipleRestrictPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "multiple_restrict_plugins")
}
// invalidRulePlugin registers a single plugin whose Restrict Rule carries a
// syntactically-invalid MaxRisk value. Neither the Builder nor the staging
// Registrar validate Rule *contents* (only nilness) -- semantic validation
// happens later, in cmdpolicy.ValidateRule, called from
// cmd/platform_bootstrap.go's applyUserPolicyPruning -> cmdpolicy.Resolve.
const invalidRulePlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("invalid-rule", "0.1.0").
Restrict(&platform.Rule{
Name: "bad-risk",
Allow: []string{"docs/**"},
MaxRisk: platform.Risk("bogus"),
}).
MustBuild())
}
`
// TestInstallInvalidRulePin pins reason_code=invalid_rule. Observed real output
// (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"invalid-rule\" rule invalid: invalid max_risk \"bogus\":
// must be one of read|write|high-risk-write",
// "hint":"plugin policy configuration is broken (reason_code invalid_rule);
// fix the plugin's Restrict rule or remove the conflicting plugin"}}
func TestInstallInvalidRulePin(t *testing.T) {
bin := buildFork(t, "invalid-rule", invalidRulePlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "invalid_rule")
}
// installFailedPlugin is a hand-written bare platform.Plugin (not
// Builder-based -- Install returning a plain error is not expressible
// through the Builder's fluent API) whose Install always returns an error.
// FailurePolicy=FailClosed makes the host abort rather than warn+skip.
const installFailedPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"errors"
"github.com/larksuite/cli/extension/platform"
)
type installFailed struct{}
func (installFailed) Name() string { return "install-failed" }
func (installFailed) Version() string { return "0.1.0" }
func (installFailed) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (installFailed) Install(r platform.Registrar) error {
return errors.New("deliberate install failure")
}
func init() { platform.Register(installFailed{}) }
`
// TestInstallFailedPin pins reason_code=install_failed. Observed real output
// (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"install-failed\" (install_failed): Install returned
// error: deliberate install failure",
// "hint":"plugin \"install-failed\" failed to install (reason_code
// install_failed); fix or remove the plugin before running commands"}}
func TestInstallFailedPin(t *testing.T) {
bin := buildFork(t, "install-failed", installFailedPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "install_failed")
}
// installPanicPlugin is a hand-written bare Plugin whose Install panics.
// safeCallInstall (internal/platform/host.go) recovers and converts the
// panic into a typed install_panic error rather than crashing the binary.
const installPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
type installPanic struct{}
func (installPanic) Name() string { return "install-panic" }
func (installPanic) Version() string { return "0.1.0" }
func (installPanic) Capabilities() platform.Capabilities {
return platform.Capabilities{FailurePolicy: platform.FailClosed}
}
func (installPanic) Install(r platform.Registrar) error {
panic("deliberate install panic")
}
func init() { platform.Register(installPanic{}) }
`
// TestInstallPanicPin pins reason_code=install_panic. Observed real output
// (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"install-panic\" (install_panic): Install panicked:
// deliberate install panic",
// "hint":"plugin \"install-panic\" failed to install (reason_code
// install_panic); fix or remove the plugin before running commands"}}
func TestInstallPanicPin(t *testing.T) {
bin := buildFork(t, "install-panic", installPanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "install_panic")
}
// pluginNamePanicPlugin is a hand-written bare Plugin whose Name() panics.
// InstallAll's outer loop calls safeCallName BEFORE it ever reads
// Capabilities(), so this aborts unconditionally regardless of what
// Capabilities() would have declared (host.go's isUntrustedConfigError
// list) -- Capabilities() here is a throwaway zero value, never invoked.
const pluginNamePanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
type pluginNamePanic struct{}
func (pluginNamePanic) Name() string { panic("deliberate name panic") }
func (pluginNamePanic) Version() string { return "0.1.0" }
func (pluginNamePanic) Capabilities() platform.Capabilities {
return platform.Capabilities{}
}
func (pluginNamePanic) Install(r platform.Registrar) error { return nil }
func init() { platform.Register(pluginNamePanic{}) }
`
// TestInstallPluginNamePanicPin pins reason_code=plugin_name_panic. Observed real
// output (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"<unknown>\" (plugin_name_panic): Plugin.Name()
// panicked: deliberate name panic",
// "hint":"plugin \"<unknown>\" failed to install (reason_code
// plugin_name_panic); fix or remove the plugin before running commands"}}
func TestInstallPluginNamePanicPin(t *testing.T) {
bin := buildFork(t, "plugin-name-panic", pluginNamePanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "plugin_name_panic")
}
// capabilitiesPanicPlugin is a hand-written bare Plugin whose Capabilities()
// panics. readFailurePolicy (internal/platform/host.go) re-invokes
// Capabilities() to decide FailOpen vs FailClosed, panics again, and its
// recover leaves the pre-set FailClosed default in place -- so this aborts
// unconditionally too, without the plugin ever declaring a real policy.
const capabilitiesPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
type capabilitiesPanic struct{}
func (capabilitiesPanic) Name() string { return "capabilities-panic" }
func (capabilitiesPanic) Version() string { return "0.1.0" }
func (capabilitiesPanic) Capabilities() platform.Capabilities {
panic("deliberate capabilities panic")
}
func (capabilitiesPanic) Install(r platform.Registrar) error { return nil }
func init() { platform.Register(capabilitiesPanic{}) }
`
// TestInstallCapabilitiesPanicPin pins reason_code=capabilities_panic. Observed
// real output (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"capabilities-panic\" (capabilities_panic):
// Plugin.Capabilities() panicked: deliberate capabilities panic",
// "hint":"plugin \"capabilities-panic\" failed to install (reason_code
// capabilities_panic); fix or remove the plugin before running commands"}}
func TestInstallCapabilitiesPanicPin(t *testing.T) {
bin := buildFork(t, "capabilities-panic", capabilitiesPanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "capabilities_panic")
}
// restrictsMismatchPlugin is a hand-written bare Plugin that declares
// Capabilities.Restricts=true (paired with the required FailClosed) but
// whose Install never calls r.Restrict. stagingRegistrar.validateSelf
// (internal/platform/staging.go) checks this exact declared-vs-actual
// consistency after Install returns.
const restrictsMismatchPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
type restrictsMismatch struct{}
func (restrictsMismatch) Name() string { return "restricts-mismatch" }
func (restrictsMismatch) Version() string { return "0.1.0" }
func (restrictsMismatch) Capabilities() platform.Capabilities {
return platform.Capabilities{Restricts: true, FailurePolicy: platform.FailClosed}
}
func (restrictsMismatch) Install(r platform.Registrar) error { return nil }
func init() { platform.Register(restrictsMismatch{}) }
`
// TestInstallRestrictsMismatchPin pins reason_code=restricts_mismatch.
// Observed real output (schema):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"plugin \"restricts-mismatch\" (restricts_mismatch):
// Capabilities.Restricts=true but Install did not call r.Restrict",
// "hint":"plugin \"restricts-mismatch\" failed to install (reason_code
// restricts_mismatch); fix or remove the plugin before running commands"}}
func TestInstallRestrictsMismatchPin(t *testing.T) {
bin := buildFork(t, "restricts-mismatch", restrictsMismatchPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "restricts_mismatch")
}
// mustBuildPanicPlugin calls MustBuild() on a Builder with an invalid plugin
// name ("BadName!!" fails ^[a-z0-9][a-z0-9-]*$). This panics from
// plugin.init(), which runs from the blank-import BEFORE main() has a
// chance to install any recover-and-envelope guard -- so, unlike every
// other case here, this crashes the process outright: no JSON envelope,
// non-zero exit, a raw Go panic trace on stderr.
const mustBuildPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(platform.NewPlugin("BadName!!", "0.1.0").MustBuild())
}
`
// TestInstallMustBuildInitPanicCrashesBinary pins the MustBuild init-panic crash
// shape. This is NOT the plugin_install envelope -- it is a bare Go panic
// crash, because it happens in init(), before main()'s recover guard
// exists. Observed real output (schema):
//
// exit=2
// stderr=panic: plugin "BadName!!": invalid plugin name "BadName!!": must
// match ^[a-z0-9][a-z0-9-]*$
//
// goroutine 1 [running]:
// larkcustomer/plugin.init.0(...)
// .../plugin/plugin.go:7
// ...
func TestInstallMustBuildInitPanicCrashesBinary(t *testing.T) {
bin := buildFork(t, "mustbuild-panic", mustBuildPanicPlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
if res.exit == 0 {
t.Fatalf("expected non-zero exit on init panic; exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if gjson.Valid(res.stderr) {
t.Fatalf("expected a raw panic trace, not a JSON envelope; stderr=%s", res.stderr)
}
if !strings.Contains(res.stderr, "panic:") {
t.Fatalf("stderr missing Go panic trace; stderr=%s", res.stderr)
}
if !strings.Contains(res.stderr, `invalid plugin name "BadName!!"`) {
t.Errorf("stderr missing the Builder's invalid-name message; stderr=%s", res.stderr)
}
}

View File

@@ -0,0 +1,298 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"strings"
"testing"
"github.com/tidwall/gjson"
)
// auditPlugin registers a single After observer matching every command that
// logs "[audit] <path>" to stderr. Based on (a simplified form of) the
// shipped extension/platform/examples/audit-observer example.
const auditPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"fmt"
"os"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("audit", "0.1.0").
Observer(platform.After, "log", platform.All(),
func(_ context.Context, inv platform.Invocation) {
fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path())
}).
FailOpen().
MustBuild())
}
`
// TestObservePin pins the audit observer's stderr line format. Observed
// real output (docs +fetch --doc nonexistent, a real read-risk command that
// fails downstream with an API error unrelated to the plugin):
//
// exit=1
// stderr=[audit] docs/+fetch
// {"ok":false,"identity":"user","error":{"type":"api","subtype":"unknown",...}}
//
// The observer line always leads, on its own line, before whatever the
// command itself writes to stderr.
func TestObservePin(t *testing.T) {
bin := buildFork(t, "audit", auditPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if !strings.Contains(res.stderr, "[audit] docs/+fetch\n") {
t.Fatalf("stderr missing audit line; stderr=%s", res.stderr)
}
}
// auditRestrictPlugin combines an After observer with a Restrict rule in one
// plugin, so a denied command's stderr carries both the observer's
// side-effect and the denial envelope: the framework's contract is that
// After observers fire even for denied commands (see
// extension/platform/invocation.go's DeniedByPolicy doc).
const auditRestrictPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"fmt"
"os"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("audit-restrict", "0.1.0").
Observer(platform.After, "log", platform.All(),
func(_ context.Context, inv platform.Invocation) {
fmt.Fprintf(os.Stderr, "[audit] %s\n", inv.Cmd().Path())
}).
Restrict(&platform.Rule{
Name: "agent-readonly",
Allow: []string{"docs/**", "im/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// TestObserveOnDeniedPin pins the audit-contract case: a denied command's
// stderr carries BOTH the observer's audit line AND the denial envelope,
// concatenated in a single stream, audit line first. Observed real output
// (docs +update --doc-token x --content y, denied write_not_allowed):
//
// exit=2
// stderr=[audit] docs/+update
// {"ok":false,"error":{"type":"validation","subtype":"failed_precondition",...}}
//
// The leading "[audit] ..." line means gjson.Valid on the raw stderr is
// false; the JSON envelope must be sliced out from the first '{' before
// parsing it as JSON.
func TestObserveOnDeniedPin(t *testing.T) {
bin := buildFork(t, "audit-restrict", auditRestrictPlugin)
res := run(t, bin, "docs", "+update", "--doc-token", "x", "--content", "y")
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !strings.Contains(res.stderr, "[audit] docs/+update\n") {
t.Fatalf("stderr missing audit line on a denied command; stderr=%s", res.stderr)
}
i := strings.Index(res.stderr, "{")
if i < 0 {
t.Fatalf("stderr has no JSON envelope after the audit line; stderr=%s", res.stderr)
}
envelope := res.stderr[i:]
if !gjson.Valid(envelope) {
t.Fatalf("sliced envelope not JSON: %s", envelope)
}
if got := gjson.Get(envelope, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(envelope, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if hint := gjson.Get(envelope, "error.hint").String(); !strings.Contains(hint, "reason_code write_not_allowed") {
t.Errorf("hint=%q want to contain reason_code write_not_allowed", hint)
}
}
// observerPanicPlugin's After observer panics unconditionally. runObserverSafe
// (internal/hook/install.go) must isolate the panic so command dispatch
// still completes normally.
const observerPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("observer-panic", "0.1.0").
Observer(platform.After, "log", platform.All(),
func(_ context.Context, _ platform.Invocation) {
panic("boom")
}).
FailOpen().
MustBuild())
}
`
// TestObserverPanicIsolationPin pins panic isolation: an After observer that
// always panics must not affect the command's own outcome. The assertion is
// baseline-relative -- the panicking-observer fork's exit code must equal the
// noop-observer baseline fork's for the same `schema` command (a local,
// network-free, read-risk command), whatever that shared exit code is.
// Observed real output at pin time:
//
// panicking: exit=0 stderr=warning: hook "observer-panic.log" panicked: boom
// baseline: exit=0 stderr=(empty)
//
// The panic is fully swallowed by runObserverSafe (internal/hook/install.go),
// surfacing only as a stderr warning line, never as a non-zero exit or crash.
func TestObserverPanicIsolationPin(t *testing.T) {
bin := buildFork(t, "observer-panic", observerPanicPlugin)
res := run(t, bin, "schema")
baselineBin := buildFork(t, "smoke", noopPlugin)
baseline := run(t, baselineBin, "schema")
if res.exit != baseline.exit {
t.Fatalf("panicking-observer exit=%d differs from baseline exit=%d; stderr=%s", res.exit, baseline.exit, res.stderr)
}
if !strings.Contains(res.stderr, `warning: hook "observer-panic.log" panicked: boom`) {
t.Errorf("stderr missing panic-isolation warning; stderr=%s", res.stderr)
}
}
// wrapAbortPlugin's Wrapper short-circuits every command with an AbortError
// instead of calling next.
const wrapAbortPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("wrap-abort", "0.1.0").
Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler {
return func(ctx context.Context, inv platform.Invocation) error {
return &platform.AbortError{
HookName: "guard",
Reason: "blocked for test",
}
}
}).
FailOpen().
MustBuild())
}
`
// TestWrapAbortPin pins the wrap-abort envelope shape. An *AbortError
// returned by a Wrapper is converted by wrapAbortError
// (internal/hook/install.go) into the SAME envelope shape as a Restrict
// denial -- error.type=="validation", error.subtype=="failed_precondition"
// -- NOT a distinct "hook" error type. Observed real output (docs +fetch
// --doc nonexistent, wrapper aborts unconditionally before calling next):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"hook \"wrap-abort.guard\" aborted: blocked for test",
// "hint":"plugin hook \"wrap-abort.guard\" aborted this command; adjust the
// request to satisfy the hook's policy, or remove the plugin"}}
//
// HookName is namespaced to "<plugin-name>.<hookName>" ("wrap-abort.guard")
// regardless of the HookName the plugin set on the AbortError itself
// (namespacedWrap overwrites it) -- see internal/hook/install.go.
func TestWrapAbortPin(t *testing.T) {
bin := buildFork(t, "wrap-abort", wrapAbortPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-abort.guard" aborted: blocked for test`) {
t.Errorf("error.message=%q want to contain the namespaced hook name and Reason", msg)
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-abort.guard" aborted this command`) {
t.Errorf("error.hint=%q want to contain the abort hint", hint)
}
}
// wrapPanicPlugin's Wrapper factory panics on every invocation (the factory
// closure itself, not the returned Handler).
const wrapPanicPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("wrap-panic", "0.1.0").
Wrap("guard", platform.All(), func(next platform.Handler) platform.Handler {
panic("wrap boom")
}).
FailOpen().
MustBuild())
}
`
// TestWrapPanicPin pins the wrap-panic envelope shape: a panicking Wrapper
// factory does not crash the process. recoverWrap (internal/hook/install.go)
// converts the panic into the same validation/failed_precondition shape as
// wrap-abort, with a distinct message/hint pair. Observed real output (docs
// +fetch --doc nonexistent, wrapper factory panics unconditionally):
//
// exit=2
// stderr={"ok":false,"error":{"type":"validation","subtype":"failed_precondition",
// "message":"hook \"wrap-panic.guard\" panicked: wrap boom",
// "hint":"plugin hook \"wrap-panic.guard\" crashed while handling this
// command; report the panic to the plugin author or remove the plugin"}}
func TestWrapPanicPin(t *testing.T) {
bin := buildFork(t, "wrap-panic", wrapPanicPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if msg := gjson.Get(res.stderr, "error.message").String(); !strings.Contains(msg, `hook "wrap-panic.guard" panicked: wrap boom`) {
t.Errorf("error.message=%q want to contain the namespaced hook name and panic value", msg)
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, `plugin hook "wrap-panic.guard" crashed while handling this command`) {
t.Errorf("error.hint=%q want to contain the panic hint", hint)
}
}

View File

@@ -0,0 +1,205 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"strings"
"testing"
"github.com/tidwall/gjson"
)
// readonlyPlugin registers a Restrict rule that only allows read-risk
// commands under the docs/** and im/** domains. It mirrors the official
// example readonly-policy configuration.
const readonlyPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("readonly", "0.1.0").
Restrict(&platform.Rule{
Name: "agent-readonly",
Allow: []string{"docs/**", "im/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// TestReadonlyDenial asserts the VERIFIED denial envelope shape: stderr is
// valid JSON, error.type=="validation", error.subtype=="failed_precondition",
// error.hint contains the literal "reason_code <X>" substring, and the
// process exits 2. reason_code lives only in the hint string, not a
// structured field.
func TestReadonlyDenial(t *testing.T) {
bin := buildFork(t, "readonly", readonlyPlugin)
// Note: reason_code mixed_children_policy is intentionally NOT covered here.
// It requires a parent command whose *enumerated children* have mixed
// allow/deny outcomes, which needs the full command tree from API metadata.
// This L4 harness builds a bare-module fork (embedded stub only), so offline
// a parent like "sheets" has no known children and collapses to
// domain_not_allowed -- identical to the "leaf out of allow list" case and
// not a distinct reason_code. Covered instead by the in-process cmdpolicy
// unit tests, which construct a mixed-children tree directly.
cases := []struct {
name string
args []string
reasonCode string
}{
{"write in allowed domain", []string{"docs", "+update", "--doc-token", "x", "--content", "y"}, "write_not_allowed"},
{"leaf out of allow list", []string{"schema"}, "domain_not_allowed"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
assertReasonCodeEnvelope(t, run(t, bin, tc.args...), tc.reasonCode)
})
}
}
// TestReadonlyAllows asserts the allow-path: a read command inside an
// allowed domain must NOT be denied by the policy gate. It may still fail
// downstream (e.g. api/auth error), but that failure must not carry the
// denial envelope shape and must not exit 2.
func TestReadonlyAllows(t *testing.T) {
bin := buildFork(t, "readonly", readonlyPlugin)
res := run(t, bin, "docs", "+fetch", "--doc", "nonexistent")
if res.exit == 2 {
t.Fatalf("read command was denied (exit=2); stderr=%s", res.stderr)
}
if gjson.Valid(res.stderr) && gjson.Get(res.stderr, "error.subtype").String() == "failed_precondition" {
t.Errorf("read command produced a denial envelope; stderr=%s", res.stderr)
}
}
// identityPlugin registers a Restrict rule scoped to bot identities only.
// im +messages-search declares AuthTypes:["user"] (see
// shortcuts/im/im_messages_search.go), so it has no intersection with the
// rule's bot-only whitelist regardless of which --as value the caller
// passes: platform.Rule.Identities is checked against the command's own
// static supported-identities annotation, not the runtime --as flag.
const identityPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("identity-restrict", "0.1.0").
Restrict(&platform.Rule{
Name: "bot-only",
Allow: []string{"im/**"},
MaxRisk: platform.RiskRead,
Identities: []platform.Identity{platform.IdentityBot},
}).
MustBuild())
}
`
// denylistPlugin registers a Restrict rule that allows the docs/** domain
// but explicitly denies docs/+search (a real read-risk leaf, see
// shortcuts/doc/docs_search.go). Deny has priority over Allow, so the
// command is rejected before MaxRisk is even consulted.
const denylistPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("denylist-restrict", "0.1.0").
Restrict(&platform.Rule{
Name: "deny-search",
Allow: []string{"docs/**"},
Deny: []string{"docs/+search"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// multiRulePlugin registers two scope-exclusive Restrict rules (im-only,
// docs-only). A command outside both domains (e.g. the top-level "schema"
// command, itself read-risk and already proven to hit domain_not_allowed
// under a single Allow:["docs/**","im/**"] rule in TestReadonlyDenial) is
// rejected by both rules, so cmdpolicy's OR-engine collapses the two
// per-rule denials into the aggregate reason_code "no_matching_rule".
const multiRulePlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import "github.com/larksuite/cli/extension/platform"
func init() {
platform.Register(
platform.NewPlugin("multi-rule-restrict", "0.1.0").
Restrict(&platform.Rule{
Name: "im-only",
Allow: []string{"im/**"},
MaxRisk: platform.RiskRead,
}).
Restrict(&platform.Rule{
Name: "docs-only",
Allow: []string{"docs/**"},
MaxRisk: platform.RiskRead,
}).
MustBuild())
}
`
// assertReasonCodeEnvelope asserts the VERIFIED envelope shape shared by every
// reason_code across this package -- both policy denials (this file) and
// install-time failures (install_test.go): exit 2, valid JSON on stderr,
// error.type=="validation", error.subtype=="failed_precondition", and
// error.hint containing "reason_code <wantReasonCode>". Both paths render
// through the SAME cmd/platform_guards.go WithHint(...) family, embedding
// reason_code in the hint STRING, not a structured error.detail.reason_code
// field (contradicting internal/platform/error.go:34's comment).
func assertReasonCodeEnvelope(t *testing.T, res result, wantReasonCode string) {
t.Helper()
if res.exit != 2 {
t.Fatalf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
if !gjson.Valid(res.stderr) {
t.Fatalf("stderr not JSON: %s", res.stderr)
}
if got := gjson.Get(res.stderr, "error.type").String(); got != "validation" {
t.Errorf("error.type=%q want validation", got)
}
if got := gjson.Get(res.stderr, "error.subtype").String(); got != "failed_precondition" {
t.Errorf("error.subtype=%q want failed_precondition", got)
}
if hint := gjson.Get(res.stderr, "error.hint").String(); !strings.Contains(hint, "reason_code "+wantReasonCode) {
t.Errorf("hint=%q want to contain reason_code %s", hint, wantReasonCode)
}
}
// TestIdentityMismatchDenial pins reason_code=identity_mismatch: a bot-only
// rule rejects a command whose declared AuthTypes don't include "bot".
func TestIdentityMismatchDenial(t *testing.T) {
bin := buildFork(t, "identity", identityPlugin)
res := run(t, bin, "im", "+messages-search", "--as", "user")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "identity_mismatch")
}
// TestDenylistDenial pins reason_code=command_denylisted: a Deny glob hit
// rejects the command even though it also matches Allow.
func TestDenylistDenial(t *testing.T) {
bin := buildFork(t, "denylist", denylistPlugin)
res := run(t, bin, "docs", "+search")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "command_denylisted")
}
// TestMultiRuleDenial pins reason_code=no_matching_rule: a command rejected
// by every rule in a multi-Restrict() plugin gets the aggregate reason_code,
// not either rule's own per-rule reason_code.
func TestMultiRuleDenial(t *testing.T) {
bin := buildFork(t, "multirule", multiRulePlugin)
res := run(t, bin, "schema")
t.Logf("exit=%d stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
assertReasonCodeEnvelope(t, res, "no_matching_rule")
}

View File

@@ -0,0 +1,69 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package plugin_e2e
import (
"os"
"path/filepath"
"testing"
)
// TestMain archives HEAD's committed tree once for the whole package before
// any fork build runs. It lives here (not in harness.go) because `go test`
// only discovers TestMain in a _test.go file — a TestMain defined in a plain
// .go file is silently never invoked.
// NOTE: exactly one TestMain is allowed per package — do not add another in other _test.go files here.
func TestMain(m *testing.M) {
root, err := repoRoot()
if err != nil {
panic("locate repo root: " + err.Error())
}
baseDir, err = os.MkdirTemp("", "plugin-e2e-")
if err != nil {
panic("mkdtemp: " + err.Error())
}
cleanTree = filepath.Join(baseDir, "larkcli-clean")
if err := os.MkdirAll(cleanTree, 0o755); err != nil {
panic("mkdir clean tree: " + err.Error())
}
if err := gitArchive(root, cleanTree); err != nil {
panic("git archive: " + err.Error())
}
code := m.Run()
_ = os.RemoveAll(baseDir)
os.Exit(code)
}
// noopPlugin registers a plugin that installs nothing observable, proving
// the blank-import -> init -> Register -> InstallAll assembly chain links
// and the fork boots.
const noopPlugin = `// Code generated by plugin_e2e; DO NOT EDIT.
package plugin
import (
"context"
"github.com/larksuite/cli/extension/platform"
)
func init() {
platform.Register(
platform.NewPlugin("smoke", "0.0.1").
Observer(platform.After, "noop", platform.All(),
func(_ context.Context, _ platform.Invocation) {}).
FailOpen().
MustBuild())
}
`
func TestSmokeForkBoots(t *testing.T) {
bin := buildFork(t, "smoke", noopPlugin)
res := run(t, bin, "--help")
if res.exit != 0 {
t.Fatalf("--help exit=%d stderr=%s", res.exit, res.stderr)
}
if res.stdout == "" {
t.Fatalf("--help produced empty stdout")
}
}

View File

@@ -0,0 +1,690 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//go:build authsidecar
// Package sidecar_e2e proves the sidecar auth-proxy wire protocol end-to-end,
// offline and secret-free: a real fork binary (built with -tags authsidecar,
// exercising the REAL extension/transport/sidecar interceptor) signs a
// request with HMAC-SHA256 and routes it to an in-test sidecar, which
// verifies the signature using the REAL sidecar.Verify / sidecar.CanonicalRequest
// from github.com/larksuite/cli/sidecar, injects a synthetic token, and
// forwards to an in-test mock upstream.
//
// DEVIATION FROM THE ORIGINAL PLAN: the plan called for driving the real
// sidecar/server-demo binary (built with -tags authsidecar_demo) as the
// middle process. That is infeasible for an OFFLINE test, for three
// independent reasons, all verified in source:
//
// 1. sidecar/server-demo/handler.go:171 resolves a REAL token via
// h.cred.ResolveToken(...), which errors out unless the machine has run
// `lark-cli auth login` — there is no way to make it return a token
// without live credentials.
// 2. sidecar/server-demo/main.go builds handler.allowedHosts from
// core.ResolveEndpoints(BrandFeishu/BrandLark) only — real feishu/lark
// hosts. An in-test mock (127.0.0.1:<port>) is never in that allowlist
// and would be rejected with 403 (handler.go step 4).
// 3. sidecar/server-demo/handler.go:184 pins the forward scheme to
// "https://" + targetHost, ignoring the client-supplied scheme. It can
// never be redirected to an http:// mock.
//
// server-demo's verify+inject logic is ALREADY covered by
// `go test -tags authsidecar_demo ./sidecar/server-demo/` (see the
// sidecar-test Makefile target, item 3) — that is unit-level coverage of the
// same code paths this file would otherwise exercise via a real subprocess.
//
// So instead, this test builds its OWN in-test sidecar (an httptest.Server)
// built on the real protocol package (sidecar.Verify, sidecar.CanonicalRequest,
// sidecar.BodySHA256, the Header* / Sentinel* / Identity* constants) — the
// same symbols server-demo itself uses. Against server-demo/handler.go's
// numbered steps, the coverage accounting is:
//
// - steps 0-3 (protocol version, timestamp presence, body SHA256, HMAC
// verification): MIRRORED in the in-test handler. Note server-demo's
// step 1 checks timestamp presence only — no freshness/skew window
// exists there either; the timestamp's integrity is covered by the HMAC.
// - steps 4/5/5.5 (target-host / identity / auth-header allowlists): NOT
// enforced in the handler (a mock's 127.0.0.1 host can never be in a
// real allowlist); replaced by post-hoc test assertions that the docs
// request named the real Feishu host, identity=user, and the committed
// auth header was present.
// - step 6 (resolve real token): replaced by a synthetic injected token —
// the point of the offline design.
// - steps 7-10 (build forward request, inject, forward, relay response):
// mirrored in shape, except the forward goes to the in-test mock's URL
// instead of "https://"+targetHost (deliberate, documented on
// forwardWithInjectedToken).
// - step 11 (audit log): not covered; irrelevant to the wire contract.
//
// This is the standard shape for this kind of test: one real external
// process (the fork binary, compiled with the production interceptor code)
// plus two in-process httptest.Server stand-ins (sidecar, upstream). It
// proves the real wire protocol end-to-end without requiring live
// credentials, real feishu/lark hosts, or TLS.
//
// Every key/token/app-id here is an obviously-synthetic placeholder; nothing
// in this file can authenticate against anything real.
package sidecar_e2e
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/larksuite/cli/sidecar"
)
// Synthetic, obviously-fake fixtures. None of these are real secrets.
const (
testProxyKey = "test-proxy-key-not-a-real-secret-000000000000"
testAppID = "cli_test_app_not_real"
injectedToken = "fake-injected-token-not-real"
// testDocToken is the --doc argument runFork passes; the docs +fetch call
// becomes POST /open-apis/docs_ai/v1/documents/<testDocToken>/fetch. Sharing
// it keeps the request marker below in sync with the command invocation.
testDocToken = "nonexistent"
// docsReqPath is the exact path of the TARGET docs +fetch request among
// every request the fork routes through the proxy. `docs +fetch --as user`
// resolves a sentinel UAT, and the credential layer then verifies it with
// a mandatory /open-apis/authen/v1/user_info probe (see
// internal/credential/credential_provider.go enrichUserInfo) — so a second
// request also flows through the sidecar. Asserting on whichever arrived
// last would let that identity probe masquerade as the docs request; we
// select the docs call by its full path (exact match, not a substring — a
// wrong API prefix or version must not slip through).
docsReqPath = "/open-apis/docs_ai/v1/documents/" + testDocToken + "/fetch"
// wantProxyTargetHost is the real Feishu open-platform host the interceptor
// must name as the proxy target for BRAND=feishu. The request is never
// actually forwarded there (the in-test sidecar redirects to the mock); the
// header only records where the fork BELIEVED it was going, and it is HMAC
// signing input, so it must be exactly the real host.
wantProxyTargetHost = "open.feishu.cn"
)
// TestSidecarHMACRoundTrip drives the whole wire protocol as three named
// steps so the flow is readable at a glance; each step's mechanics live in a
// dedicated helper below.
func TestSidecarHMACRoundTrip(t *testing.T) {
// Two in-process stand-ins: the mock upstream (for open.feishu.cn) and the
// in-test sidecar (server-demo's verify+inject, via the real protocol pkg).
upstream := startMockUpstream(t)
sc := startInTestSidecar(t, []byte(testProxyKey), upstream.URL)
// One real external process: lark-cli built with -tags authsidecar, run
// fully offline against the in-test sidecar.
bin := buildAuthsidecarFork(t)
res := runFork(t, bin, sc.URL)
// Diagnostic dump (shown only on failure or -v): the full request set, so a
// failure makes plain which requests flowed and which one the assertions
// targeted, instead of guessing about the last-arriving request.
for _, s := range sc.seenAll() {
t.Logf("sidecar saw: %s %s target=%q identity=%q verifyRan=%v verifyErr=%v",
s.req.method, s.req.path, s.req.headers.Get(sidecar.HeaderProxyTarget),
s.req.headers.Get(sidecar.HeaderProxyIdentity), s.verifyRan, s.verifyErr)
}
for _, r := range upstream.sink.all() {
t.Logf("upstream saw: %s %s auth=%q", r.method, r.path, r.headers.Get("Authorization"))
}
t.Logf("fork exit=%d\nstdout=%s\nstderr=%s", res.exit, res.stdout, res.stderr)
// Assert the fork's command itself succeeded end-to-end, not just that some
// bytes reached the sidecar.
assertForkSucceeded(t, res)
// Assert the three properties of a correct round trip, scoped to the DOCS
// request (not an auxiliary identity probe).
assertInterceptorSigned(t, sc) // (a)+(c) fork -> sidecar
assertInjectedTokenReachedUpstream(t, upstream) // (b) sidecar -> upstream
}
// --- request capture -------------------------------------------------------
// capturedRequest snapshots the parts of an *http.Request that matter for
// assertions, taken before the request (and its body reader) is consumed or
// goes out of scope.
type capturedRequest struct {
method string
path string
headers http.Header
body []byte
}
// requestSink stores EVERY request a stub server saw, in arrival order,
// guarded so the httptest handler goroutine and the test goroutine can hand
// them over safely. Capturing all requests (not just the last) is what closes
// the false-green window: the fork may route more than one request through the
// proxy, and the target docs request is not guaranteed to be the last.
type requestSink struct {
mu sync.Mutex
reqs []*capturedRequest
}
func (s *requestSink) capture(r *http.Request, body []byte) *capturedRequest {
snap := &capturedRequest{
method: r.Method,
path: r.URL.RequestURI(),
headers: r.Header.Clone(),
body: body,
}
s.mu.Lock()
s.reqs = append(s.reqs, snap)
s.mu.Unlock()
return snap
}
func (s *requestSink) all() []*capturedRequest {
s.mu.Lock()
defer s.mu.Unlock()
return append([]*capturedRequest(nil), s.reqs...)
}
// find returns the first captured request whose path equals path, or nil.
func (s *requestSink) find(path string) *capturedRequest {
s.mu.Lock()
defer s.mu.Unlock()
for _, r := range s.reqs {
if r.path == path {
return r
}
}
return nil
}
// --- mock upstream (stands in for open.feishu.cn) --------------------------
type mockUpstream struct {
*httptest.Server
sink requestSink
}
func startMockUpstream(t *testing.T) *mockUpstream {
t.Helper()
m := &mockUpstream{}
m.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
m.sink.capture(r, body)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// Respond per path so each forwarded request parses as success: the
// identity probe needs authen/v1/user_info's {data:{open_id,name}} to
// resolve cleanly; the docs +fetch is satisfied by the generic code:0
// envelope. A single canned body would make the identity probe error.
_, _ = w.Write(mockResponseFor(r.URL.Path))
}))
t.Cleanup(m.Close)
return m
}
// mockResponseFor returns a minimal success body matching the API the given
// path addresses. Unknown paths get a generic code:0 envelope.
func mockResponseFor(path string) []byte {
if strings.Contains(path, "/authen/v1/user_info") {
return []byte(`{"code":0,"msg":"success","data":{"open_id":"ou_mock","name":"mock user"}}`)
}
return []byte(`{"code":0,"msg":"success","data":{}}`)
}
// --- in-test sidecar (mirrors server-demo/handler.go verify+inject) --------
// sidecarSeen is one request the in-test sidecar received, together with the
// per-request verification outcome. Tracking this per request (not as a single
// last-write-wins field) lets assertions check the verification that belongs
// to the DOCS request specifically.
type sidecarSeen struct {
req *capturedRequest
verifyRan bool
verifyErr error
}
type inTestSidecar struct {
*httptest.Server
key []byte
upstreamURL string
mu sync.Mutex // guards seen
seen []sidecarSeen
}
func startInTestSidecar(t *testing.T, key []byte, upstreamURL string) *inTestSidecar {
t.Helper()
s := &inTestSidecar{key: key, upstreamURL: upstreamURL}
s.Server = httptest.NewServer(http.HandlerFunc(s.handle))
t.Cleanup(s.Close)
return s
}
// handle is the request flow: capture -> verify (steps 0-3) -> inject+forward.
func (s *inTestSidecar) handle(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
snap := &capturedRequest{
method: r.Method,
path: r.URL.RequestURI(),
headers: r.Header.Clone(),
body: body,
}
authHeader, verifyRan, verifyErr, ok := s.verifyProxyRequest(w, r, body)
s.mu.Lock()
s.seen = append(s.seen, sidecarSeen{req: snap, verifyRan: verifyRan, verifyErr: verifyErr})
s.mu.Unlock()
if !ok {
return
}
s.forwardWithInjectedToken(w, r, body, authHeader)
}
// verifyProxyRequest mirrors server-demo/handler.go steps 0-3 (protocol
// version, timestamp presence, body SHA256, HMAC signature verification —
// including the target parse and identity/auth-header reads that feed the
// canonical signing string). The allowlist steps 4/5/5.5 are intentionally
// absent; the package comment's coverage accounting explains what replaces
// them. It returns the auth header the client committed to, whether HMAC
// verification (step 3) actually ran, and its result. On any earlier failure
// it writes the HTTP error and returns ok=false with verifyRan=false.
func (s *inTestSidecar) verifyProxyRequest(w http.ResponseWriter, r *http.Request, body []byte) (authHeader string, verifyRan bool, verifyErr error, ok bool) {
// Step 0: protocol version.
version := r.Header.Get(sidecar.HeaderProxyVersion)
if version != sidecar.ProtocolV1 {
http.Error(w, "unsupported "+sidecar.HeaderProxyVersion+": "+version, http.StatusBadRequest)
return "", false, nil, false
}
// Step 1: timestamp presence (matching server-demo, which enforces
// presence only — the value's integrity is covered by the HMAC below;
// an empty-but-signed timestamp would otherwise verify fine).
ts := r.Header.Get(sidecar.HeaderProxyTimestamp)
if ts == "" {
http.Error(w, "missing "+sidecar.HeaderProxyTimestamp, http.StatusBadRequest)
return "", false, nil, false
}
// Step 2: body SHA256.
claimedSHA := r.Header.Get(sidecar.HeaderBodySHA256)
if claimedSHA == "" || claimedSHA != sidecar.BodySHA256(body) {
http.Error(w, "body SHA256 mismatch", http.StatusBadRequest)
return "", false, nil, false
}
// Step 3 inputs: target host, identity, auth-header (all covered by the sig).
targetHost, perr := parseTargetHost(r.Header.Get(sidecar.HeaderProxyTarget))
if perr != nil {
http.Error(w, "invalid "+sidecar.HeaderProxyTarget+": "+perr.Error(), http.StatusForbidden)
// verifyRan=false: HMAC verification never ran; surface the parse error
// so the diagnostic dump shows why this request was rejected early.
return "", false, perr, false
}
identity := r.Header.Get(sidecar.HeaderProxyIdentity)
authHeader = r.Header.Get(sidecar.HeaderProxyAuthHeader)
// Step 3: verify HMAC signature over the canonical request.
err := sidecar.Verify(s.key, sidecar.CanonicalRequest{
Version: version,
Method: r.Method,
Host: targetHost,
PathAndQuery: r.URL.RequestURI(),
BodySHA256: claimedSHA,
Timestamp: ts,
Identity: identity,
AuthHeader: authHeader,
}, r.Header.Get(sidecar.HeaderProxySignature))
if err != nil {
http.Error(w, "HMAC verification failed: "+err.Error(), http.StatusUnauthorized)
return "", true, err, false
}
return authHeader, true, nil, true
}
// forwardWithInjectedToken mirrors server-demo's inject+forward. Unlike
// server-demo (which forwards to "https://"+targetHost), this test forwards to
// the in-test MOCK's URL — proving the sidecar's inject step without needing a
// real upstream or a route to targetHost. It strips any client-supplied auth
// headers first (the sidecar is the sole source of auth material), injects the
// synthetic token into the committed header, and relays the response back.
func (s *inTestSidecar) forwardWithInjectedToken(w http.ResponseWriter, r *http.Request, body []byte, authHeader string) {
freq, err := http.NewRequest(r.Method, s.upstreamURL+r.URL.RequestURI(), bytes.NewReader(body))
if err != nil {
http.Error(w, "failed to build forward request", http.StatusInternalServerError)
return
}
for k, vs := range r.Header {
if isProxyHeader(k) {
continue
}
for _, v := range vs {
freq.Header.Add(k, v)
}
}
freq.Header.Del("Authorization")
freq.Header.Del(sidecar.HeaderMCPUAT)
freq.Header.Del(sidecar.HeaderMCPTAT)
if authHeader == "Authorization" {
freq.Header.Set("Authorization", "Bearer "+injectedToken)
} else {
freq.Header.Set(authHeader, injectedToken)
}
resp, err := http.DefaultClient.Do(freq)
if err != nil {
http.Error(w, "forward failed: "+err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
for k, vs := range resp.Header {
for _, v := range vs {
w.Header().Add(k, v)
}
}
w.WriteHeader(resp.StatusCode)
_, _ = w.Write(respBody)
}
// seenAll returns a copy of every request the sidecar received, in order.
func (s *inTestSidecar) seenAll() []sidecarSeen {
s.mu.Lock()
defer s.mu.Unlock()
return append([]sidecarSeen(nil), s.seen...)
}
// findSeen returns the first received request whose path equals path, or nil.
func (s *inTestSidecar) findSeen(path string) *sidecarSeen {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.seen {
if s.seen[i].req.path == path {
return &s.seen[i]
}
}
return nil
}
// isProxyHeader reports whether name is one of the sidecar wire-protocol
// headers that must not be copied through to the forwarded (mock upstream)
// request. Mirrors sidecar/server-demo/handler.go's isProxyHeader.
func isProxyHeader(name string) bool {
switch http.CanonicalHeaderKey(name) {
case http.CanonicalHeaderKey(sidecar.HeaderProxyVersion),
http.CanonicalHeaderKey(sidecar.HeaderProxyTarget),
http.CanonicalHeaderKey(sidecar.HeaderProxyIdentity),
http.CanonicalHeaderKey(sidecar.HeaderProxySignature),
http.CanonicalHeaderKey(sidecar.HeaderProxyTimestamp),
http.CanonicalHeaderKey(sidecar.HeaderBodySHA256),
http.CanonicalHeaderKey(sidecar.HeaderProxyAuthHeader):
return true
}
return false
}
// parseTargetHost validates X-Lark-Proxy-Target and returns its host.
// Mirrors sidecar/server-demo/handler.go's parseTarget: the header must be
// "https://<host>" with no path, query, fragment, or userinfo. Only the host
// is used, both as HMAC signing input and to record what the fork believed
// its real destination was — the actual forward in this test always goes to
// the in-test mock, never to this host.
func parseTargetHost(target string) (string, error) {
u, err := url.Parse(target)
if err != nil {
return "", fmt.Errorf("parse: %w", err)
}
if u.Scheme != "https" {
return "", fmt.Errorf("scheme must be https, got %q", u.Scheme)
}
if u.Host == "" {
return "", fmt.Errorf("missing host")
}
if u.User != nil {
return "", fmt.Errorf("userinfo not allowed")
}
if u.Path != "" && u.Path != "/" {
return "", fmt.Errorf("path not allowed (got %q)", u.Path)
}
if u.RawQuery != "" {
return "", fmt.Errorf("query not allowed")
}
if u.Fragment != "" {
return "", fmt.Errorf("fragment not allowed")
}
return u.Host, nil
}
// --- fork build + run ------------------------------------------------------
// buildAuthsidecarFork builds the REAL lark-cli with -tags authsidecar (the
// production interceptor) and returns the binary path.
func buildAuthsidecarFork(t *testing.T) string {
t.Helper()
bin := filepath.Join(t.TempDir(), "forkbin")
build := exec.Command("go", "build", "-tags", "authsidecar", "-o", bin, ".")
build.Dir = repoRoot(t)
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build fork binary: %v\n%s", err, out)
}
return bin
}
// forkResult is the fork subprocess outcome.
type forkResult struct {
exit int
stdout string
stderr string
}
// runFork runs the fork against the in-test sidecar, fully offline, and returns
// its exit code and captured output. LARKSUITE_CLI_REMOTE_META=off is essential:
// without it the fork's startup metadata refresh hits the real
// open.feishu.cn/api/tools/open/api_definition (internal/registry/remote.go),
// which both breaks the "offline, secret-free" contract and makes the run
// depend on live network. With it set, the command still completes and the
// docs request still flows through the sidecar, but nothing leaves the machine.
func runFork(t *testing.T, binPath, sidecarURL string) forkResult {
t.Helper()
scURL, err := url.Parse(sidecarURL)
if err != nil {
t.Fatalf("parse sidecar URL: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binPath, "docs", "+fetch", "--doc", testDocToken, "--as", "user")
// Strip the host's LARKSUITE_CLI_* namespace before appending overrides: a
// developer machine exporting, say, LARKSUITE_CLI_DEFAULT_AS or
// LARKSUITE_CLI_STRICT_MODE would otherwise leak into the fork (the sidecar
// credential provider reads them via os.Getenv), so the fork's CLI-facing
// environment is exactly the variables set below, on any machine.
env := os.Environ()
base := env[:0]
for _, kv := range env {
if !strings.HasPrefix(kv, "LARKSUITE_CLI_") {
base = append(base, kv)
}
}
cmd.Env = append(base,
"LARKSUITE_CLI_AUTH_PROXY=http://"+scURL.Host,
"LARKSUITE_CLI_PROXY_KEY="+testProxyKey,
"LARKSUITE_CLI_APP_ID="+testAppID,
"LARKSUITE_CLI_BRAND=feishu",
"LARKSUITE_CLI_CONFIG_DIR="+t.TempDir(),
"LARKSUITE_CLI_REMOTE_META=off",
"LARKSUITE_CLI_NO_UPDATE_NOTIFIER=1",
"LARKSUITE_CLI_NO_SKILLS_NOTIFIER=1",
)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
runErr := cmd.Run()
exit := 0
if runErr != nil {
var ee *exec.ExitError
if errors.As(runErr, &ee) {
exit = ee.ExitCode()
} else {
t.Fatalf("run fork: %v", runErr)
}
}
return forkResult{exit: exit, stdout: stdout.String(), stderr: stderr.String()}
}
// repoRoot resolves the lark-cli module root from the test's working
// directory (which `go test` sets to the package dir, tests/sidecar_e2e).
func repoRoot(t *testing.T) string {
t.Helper()
out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output()
if err != nil {
t.Fatalf("resolve repo root: %v", err)
}
return strings.TrimSpace(string(out))
}
// --- assertions ------------------------------------------------------------
// assertForkSucceeded checks the fork command itself completed the round trip:
// exit 0 and an ok:true JSON envelope on stdout. This is what makes the docs
// request a genuine success path, not merely bytes that happened to flow.
func assertForkSucceeded(t *testing.T, res forkResult) {
t.Helper()
if res.exit != 0 {
t.Fatalf("fork exit=%d want 0; stdout=%s stderr=%s", res.exit, res.stdout, res.stderr)
}
// Parse rather than substring-match: the CLI pretty-prints stdout, so the
// envelope reads "ok": true (with a space), and the field's truth — not its
// serialized spelling — is what proves the round trip succeeded.
var env struct {
OK bool `json:"ok"`
}
if err := json.Unmarshal([]byte(res.stdout), &env); err != nil {
t.Fatalf("fork stdout is not a JSON envelope: %v; stdout=%s stderr=%s", err, res.stdout, res.stderr)
}
if !env.OK {
t.Fatalf("fork stdout ok != true (round trip did not succeed); stdout=%s stderr=%s", res.stdout, res.stderr)
}
}
// assertInterceptorSigned checks the fork -> sidecar hop (assertions a + c) for
// the DOCS request specifically: the real interceptor ran (all proxy headers
// present, identity=user, method+path+target as expected), stripped every
// real/sentinel auth header before signing, and produced a signature that
// verified against the shared key.
func assertInterceptorSigned(t *testing.T, sc *inTestSidecar) {
t.Helper()
seen := sc.findSeen(docsReqPath)
if seen == nil {
t.Fatalf("sidecar never received the docs request (path %q) — interceptor did not route it to AUTH_PROXY; saw %v",
docsReqPath, sidecarPaths(sc.seenAll()))
}
got := seen.req
if !seen.verifyRan {
t.Fatal("sidecar received the docs request but never reached HMAC verification (rejected earlier — see handler headers)")
}
if seen.verifyErr != nil {
t.Fatalf("HMAC verification failed on the fork's own signed docs request: %v", seen.verifyErr)
}
t.Logf("fork->sidecar docs headers: %v", got.headers)
// Target/method/path: prove we asserted on the real docs call to the real
// Feishu open platform, not an auxiliary request.
if got.method != http.MethodPost {
t.Errorf("docs request method = %q, want POST", got.method)
}
if targetHost, err := parseTargetHost(got.headers.Get(sidecar.HeaderProxyTarget)); err != nil {
t.Errorf("docs request %s invalid: %v", sidecar.HeaderProxyTarget, err)
} else if targetHost != wantProxyTargetHost {
t.Errorf("docs request proxy target host = %q, want %q", targetHost, wantProxyTargetHost)
}
// No real/sentinel auth ever left the fork: the interceptor strips the
// sentinel before signing, so this hop must carry no auth header at all.
if auth := got.headers.Get("Authorization"); auth != "" {
t.Fatalf("fork->sidecar hop leaked an Authorization header (want none, interceptor should have stripped it): %q", auth)
}
if v := got.headers.Get(sidecar.HeaderMCPUAT); v != "" {
t.Fatalf("fork->sidecar hop leaked %s (want none): %q", sidecar.HeaderMCPUAT, v)
}
if v := got.headers.Get(sidecar.HeaderMCPTAT); v != "" {
t.Fatalf("fork->sidecar hop leaked %s (want none): %q", sidecar.HeaderMCPTAT, v)
}
// Proxy headers must be present (proves the interceptor actually ran).
for _, h := range []string{
sidecar.HeaderProxyVersion, sidecar.HeaderProxyTarget, sidecar.HeaderProxyIdentity,
sidecar.HeaderProxySignature, sidecar.HeaderProxyTimestamp, sidecar.HeaderBodySHA256,
sidecar.HeaderProxyAuthHeader,
} {
if got.headers.Get(h) == "" {
t.Fatalf("fork->sidecar hop missing required proxy header %s", h)
}
}
if id := got.headers.Get(sidecar.HeaderProxyIdentity); id != sidecar.IdentityUser {
t.Fatalf("fork->sidecar identity = %q, want %q", id, sidecar.IdentityUser)
}
}
// assertInjectedTokenReachedUpstream checks the sidecar -> upstream hop
// (assertion b) for the DOCS request: the mock saw exactly the sidecar-injected
// synthetic token, never a sentinel or a real one — proving injection happened.
func assertInjectedTokenReachedUpstream(t *testing.T, up *mockUpstream) {
t.Helper()
got := up.sink.find(docsReqPath)
if got == nil {
t.Fatalf("mock upstream never received the forwarded docs request (path %q) — sidecar did not forward it after verification; saw %v",
docsReqPath, requestPaths(up.sink.all()))
}
t.Logf("sidecar->mock docs headers: %v", got.headers)
wantAuth := "Bearer " + injectedToken
gotAuth := got.headers.Get("Authorization")
if gotAuth != wantAuth {
t.Fatalf("mock upstream Authorization = %q, want %q", gotAuth, wantAuth)
}
// Belt-and-suspenders: the value the mock saw must not be either sentinel,
// proving the only token that ever reached "upstream" was the injected one.
if gotAuth == "Bearer "+sidecar.SentinelUAT || gotAuth == "Bearer "+sidecar.SentinelTAT {
t.Fatalf("mock upstream received a sentinel token instead of the injected one: %q", gotAuth)
}
// The sidecar wire-protocol headers are between fork and sidecar only —
// the forward must strip every one of them. Token injection alone passing
// would still be a leak if signatures/timestamps/digests reached upstream.
for _, h := range []string{
sidecar.HeaderProxyVersion, sidecar.HeaderProxyTarget, sidecar.HeaderProxyIdentity,
sidecar.HeaderProxySignature, sidecar.HeaderProxyTimestamp, sidecar.HeaderBodySHA256,
sidecar.HeaderProxyAuthHeader,
} {
if v := got.headers.Get(h); v != "" {
t.Errorf("proxy protocol header %s leaked to upstream (want stripped): %q", h, v)
}
}
}
// sidecarPaths / requestPaths render captured paths for failure messages.
func sidecarPaths(seen []sidecarSeen) []string {
paths := make([]string, len(seen))
for i, s := range seen {
paths[i] = s.req.method + " " + s.req.path
}
return paths
}
func requestPaths(reqs []*capturedRequest) []string {
paths := make([]string, len(reqs))
for i, r := range reqs {
paths[i] = r.method + " " + r.path
}
return paths
}