Compare commits

...

35 Commits

Author SHA1 Message Date
zhengzhijie
21c2e3950e feat(sheets): add chart data update shortcut 2026-07-23 17:12:08 +08:00
zhengzhijie
080fb57ad0 fix(sheets): normalize irregular chart ranges 2026-07-23 16:00:57 +08:00
zhengzhijie
87aa303ca1 fix(sheets): normalize chart range and flag inputs 2026-07-23 16:00:57 +08:00
zhengzhijie
c8f57c659e fix(sheets): prefer semantic chart shortcuts 2026-07-22 18:13:45 +08:00
zhengzhijie
e81e42029f feat(sheets): improve semantic chart workflows 2026-07-22 16:52:48 +08:00
zhengzhijie
e303da4b5e feat(sheets): add semantic chart shortcuts 2026-07-22 11:21:55 +08:00
zhengzhijie
f6bbd86303 feat(sheets): support partial chart snapshot schemas 2026-07-20 21:34:25 +08:00
xiongyuanwen-byted
67fc870582 feat(sheets): add --output-path for full-read file offload on cells/csv/table-get
Reads are capped by max_chars (default 500000; the backend tool also truncates
at ~50000 when unset). Add --output-path to +cells-get / +csv-get / +table-get:
when set, the result is written to a cwd-relative path as JSON and the char cap
is lifted to unbounded, so a large sheet lands on disk in full instead of being
clipped for stdout.

+table-get previously never sent max_chars, so it silently dropped rows past the
backend ~50000 default with no signal. It now takes --max-chars (default 500000,
sent explicitly) and surfaces truncated / truncation_warning when the read is
clipped, steering callers to --output-path for a lossless full read.
2026-07-20 11:26:46 +08:00
xiongyuanwen-byted
af8e027269 feat(sheets): support --include truncation on +cells-get
Map the new `truncation` value in --include to include_truncation_info on
the get_cell_ranges tool input, so +cells-get can return per-cell
isRowTruncated / isColTruncated. Flag metadata and reference synced from
sheet-skill-spec; flag_defs_gen.go regenerated.
2026-07-20 11:26:46 +08:00
xiongyuanwen-byted
2efadec335 feat(sheets): cut agent error rate and --help lookups (#1911)
## Background

Round 2 of eval-driven sheets optimization, rebased onto the latest `feat/lark-sheets-develop` (`8897196d`).

## Changes

- **feat(sheets): cut agent error rate and --help lookups (eval round 2)** — targets the top failure modes from round 2 evals, reducing agent error rate and the number of `--help` lookups.
- **chore(sheets): sync skill docs and flag data from sheet-skill-spec** — syncs skill docs and flag data from sheet-skill-spec.

## Notes

- During rebase, the "import mislabeled .xls workbooks by sniffing content" fix already existed on the target branch (identical patch-id), so it was auto-skipped — no duplicate.
- The target branch was force-rewritten and advanced in the meantime; the two new commits were cleanly replayed onto the new tip via `--onto` with no conflicts. One hunk touching the `--type` description in `lark-sheets-workbook.md` was auto-dropped because upstream already has the same end state — no content lost.
2026-07-20 11:26:46 +08:00
zhanghuanxu
44514ad114 fix(slides): detect visual elements outside canvas 2026-07-19 21:45:42 +08:00
liangshuo-1
4a56748bfa chore: release v1.0.72 (#1943) 2026-07-17 19:43:46 +08:00
luozhixiong01
0b6faa01bf ci: deduplicate PR runs and serialize live E2E (#1888)
* ci: deduplicate PR runs and serialize live E2E

* ci: preserve live E2E cleanup on supersession

* ci: harden live E2E supersession check

* ci: gate live E2E on dry-run planning

Make the dry-run result a hard prerequisite for live E2E so skip-mode changes never acquire the repository-wide slot. This intentionally trades one full dry-run duration of live startup latency for lower contention on the exclusive queue.

* ci: bound dry-run E2E planning

The dry-run job is now a hard prerequisite for live E2E. Bound its
execution so a stalled planning job cannot delay a PR verdict for the
default six-hour job limit.

* test: tighten live E2E supersession contract
2026-07-17 19:37:48 +08:00
LightsDancer
1efe2dfb33 feat(approval): support approval event consumption (#1924)
Register approval.instance.status_changed_v4 and approval.task.status_changed_v4 with custom flattened schemas and user-auth pre-consume subscription setup.

Handle approval subscription_type as optional multi-value pre-registration metadata: omitted values register both involved and managed relations, explicit values can be single, comma-separated, or JSON array, and consumers do not unsubscribe on exit.

Report partial approval subscription registration failures with registered and failed relation context while preserving the underlying typed error classification.

Document approval event output fields and subscription semantics, and refresh approval skill references from API metadata.
2026-07-17 18:38:29 +08:00
luozhixiong01
767386cb57 fix: stabilize drive delete E2E terminal-state checks (#1939)
* fix: converge drive delete workflow test on terminal state

* fix: narrow drive delete tolerance to the verified transient

* test: lock the delete failure guard with a subprocess contract test

* test: lock task-result and retry-exhaustion failure boundaries
2026-07-17 18:00:38 +08:00
luozhixiong01
e71c76155e test: fix drive cover download retries (#1934) 2026-07-17 17:53:01 +08:00
luozhixiong01
c363acf94e test: use tri-state wiki node identity in delete verification (#1931)
A get_node success response may omit data.node/node_token (the field is
optional), so a missing token must not be read as proof of deletion.
Classify the response as same / different / unknown: only a different
non-empty node_token proves the original node is gone (move-to-drive),
while an unknown identity keeps polling in isWikiNodeDeleted and still
attempts deletion in deleteWikiNodeAndVerify instead of leaking nodes.
2026-07-17 17:52:12 +08:00
zhanghuanxu
05285bb696 feat(slides): report resolved table size mismatches 2026-07-17 17:29:39 +08:00
zhanghuanxu
4c0f93bd6a feat(slides):lint table out of canvas 2026-07-17 17:29:39 +08:00
Yuxuan Zhao
76ebd49382 test: stabilize live e2e auth retries (#1904)
* test: stabilize live e2e auth retries

* fix(e2e): scope shared tenant credentials
2026-07-17 17:20:45 +08:00
zhengzhijiej-tech
6c14c425fc docs(sheets): use English placeholder in table-get guidance (#1936) 2026-07-17 17:01:22 +08:00
calendar-assistant
27df16d3b2 fix(vc): don't fail +detail for in-progress meetings (#1930)
An ongoing meeting has no minute/note yet, so the recording lookup in
+detail returned an unclassified error that was surfaced as a hard error,
making the whole command exit 1 / ok:false even though meeting.get had
already succeeded.

Detect the in-progress state up front (same start/end heuristic as
+meeting-events, reading raw timestamps) and skip the recording call,
returning the meeting metadata with an informational hint instead of an
error. Recording failures for ended meetings are likewise degraded to a
hint rather than failing the command.

Also note in the vc-agent skill that sending an in-meeting message only
needs meeting_id and must not pre-fetch +detail / +recording / +notes.
2026-07-17 15:54:26 +08:00
zgz2048
47dc003601 docs: document base field default values (#1500)
* docs: document base field default values

* docs(base): update field default value schema
2026-07-17 15:17:01 +08:00
zhanghuanxu
4e0a6a988c docs(slides): document table dimensions 2026-07-17 11:36:53 +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
liangshuo-1
80b3645362 chore: release v1.0.70 (#1905) 2026-07-15 21:13:37 +08:00
zhicong666-bytedance
64caef1526 fix(vc): align meeting query scopes by identity (#1850)
* fix(vc): align meeting query scopes by identity

* docs(vc): simplify meeting query scope guidance

* fix: align meeting query scopes by identity

* fix: harden vc meeting query scope preflight

* test: assert vc meeting query permission category

* fix: declare empty vc meeting query scopes

* fix: align vc meeting query scope metadata

* docs: simplify vc meeting query scope guidance

* fix: preflight vc meeting query tat scopes

* fix: make vc scope metadata lookup best effort

* fix(vc): accept compatible meeting query scopes

* test(vc): cover meeting query validate scope checks

* refactor(vc): align meeting query precheck with framework

* fix(vc): clarify meeting query scope recovery

* docs(vc): use gray access as permission fallback

* fix(vc): use user-only scope preflight for meeting queries

* fix(vc): route meeting scope hints by error code

* fix(vc): clarify compatible scope application hint

* fix(vc): simplify meeting scope recovery

* chore(vc): centralize meeting scope guidance

* fix(vc): preserve upstream meeting scope messages

* fix(vc): align meeting scope guidance by identity

* docs(vc): clarify meeting gray access guidance

* docs(vc): scope meeting query permission guidance

* refactor(vc): simplify meeting permission hints

* refactor(vc): remove unreachable permission guard

* fix(vc): guard missing meeting permission runtime

* fix(vc): guard typed nil meeting permission errors

* fix(vc): preserve app scope console URL

* refactor(vc): preserve original permission errors

* docs(vc): prioritize permission recovery hints

* docs(vc): simplify permission guidance

* docs(vc): align permission check order

* fix(vc): clarify meeting permission messages

* docs(vc): prioritize meeting permission guidance

* fix(vc): align meeting scope application link

* docs(vc): scope user permission guidance to queries

* fix(vc): narrow meeting missing scopes by identity
2026-07-15 19:40:26 +08:00
calendar-assistant
64e10a0954 docs(calendar): document setting meeting owner via full API (#1903)
Note that meeting owner must be set via vchat.meeting_settings.owner_id
with vchat.vc_type=vc, effective only for app (bot) identity on app
calendars, since +create does not expose this field.
2026-07-15 19:33:54 +08:00
185 changed files with 21821 additions and 3723 deletions

View File

@@ -1,4 +1,5 @@
name: CI
run-name: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
on:
push:
@@ -8,6 +9,12 @@ on:
types: [opened, synchronize, reopened, edited]
workflow_dispatch:
# PR metadata edits can retrigger full CI for the same head. Keep only the
# newest run for a pull request; push and manual runs use a unique run ID.
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
actions: read
@@ -47,6 +54,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 +211,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 }}
@@ -263,6 +302,11 @@ jobs:
e2e-dry-run:
needs: [unit-test, lint, script-test, deterministic-gate]
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
mode: ${{ steps.e2e_domains.outputs.mode }}
reason: ${{ steps.e2e_domains.outputs.reason }}
live_packages: ${{ steps.e2e_domains.outputs.live_packages }}
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -276,6 +320,23 @@ jobs:
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Validate CLI E2E domain outputs
env:
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
case "$E2E_MODE" in
skip)
[ -z "$E2E_LIVE_PACKAGES" ] || { echo "::error::Skip mode must not resolve live packages"; exit 1; }
;;
full|subset)
[ -n "$E2E_LIVE_PACKAGES" ] || { echo "::error::No live packages resolved for mode $E2E_MODE"; exit 1; }
;;
*)
echo "::error::Invalid CLI E2E mode: $E2E_MODE"
exit 1
;;
esac
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build
@@ -309,16 +370,22 @@ jobs:
fi
e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate]
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]
if: ${{ always() && (github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != '' }}
runs-on: ubuntu-latest
timeout-minutes: 30
# Live E2E uses one repository-wide execution slot.
concurrency:
group: lark-cli-e2e-live
cancel-in-progress: false
queue: max
permissions:
actions: read
contents: read
checks: write
env:
TEST_BOT1_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
LARKSUITE_CLI_BRAND: feishu
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
@@ -329,31 +396,68 @@ jobs:
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with:
python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
id: build_cli
run: make build
- name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
- name: Prepare shared live E2E tenant token
id: live_e2e_tat
env:
LARKSUITE_CLI_APP_ID: ${{ secrets.TEST_BOT1_APP_ID }}
TEST_BOT1_APP_SECRET: ${{ secrets.TEST_BOT1_APP_SECRET }}
run: node scripts/fetch_e2e_tat.js
- name: Run CLI E2E tests
# Keep an active Go test alive so t.Cleanup can finish. A queued stale
# run is rejected below before it can start live E2E.
if: ${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REPOSITORY: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
RUN_ID: ${{ github.run_id }}
RUN_NUMBER: ${{ github.run_number }}
RUN_GENERATION: ${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ needs.e2e-dry-run.outputs.mode }}
E2E_REASON: ${{ needs.e2e-dry-run.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ needs.e2e-dry-run.outputs.live_packages }}
E2E_TENANT_AUTH_FILE: ${{ steps.live_e2e_tat.outputs.path }}
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
if [ "$EVENT_NAME" = "pull_request" ]; then
workflow_id="$(gh api "repos/$REPOSITORY/actions/runs/$RUN_ID" --jq '.workflow_id')"
newer_runs="$(
gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs" \
-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100 |
jq -r --arg repository "$REPOSITORY" --arg generation "$RUN_GENERATION" --argjson run_number "$RUN_NUMBER" \
'.workflow_runs[] | select(.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number) | .id'
)"
if [ -n "$newer_runs" ]; then
echo "::error::Superseded before live E2E started by newer workflow run(s): $newer_runs"
exit 1
fi
fi
if [ -z "${E2E_TENANT_AUTH_FILE:-}" ] || [ ! -f "$E2E_TENANT_AUTH_FILE" ]; then
echo "::error::Missing shared live E2E tenant token file"
exit 1
fi
printf '%s\n' "$TEST_BOT1_APP_SECRET" | ./lark-cli config init --app-id "$TEST_BOT1_APP_ID" --app-secret-stdin
- name: Run CLI E2E tests
env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
export TEST_TENANT_ACCESS_TOKEN="$(cat "$E2E_TENANT_AUTH_FILE")"
rm -f "$E2E_TENANT_AUTH_FILE"
if ! LARKSUITE_CLI_APP_ID="$TEST_BOT1_APP_ID" \
LARKSUITE_CLI_TENANT_ACCESS_TOKEN="$TEST_TENANT_ACCESS_TOKEN" \
./lark-cli whoami --as bot | node -e '
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => { input += chunk; });
process.stdin.on("end", () => {
const result = JSON.parse(input);
if (result.identity !== "bot" || result.available !== true || result.tokenStatus !== "ready") process.exit(1);
});
'; then
echo "::error::Tenant credential preflight failed"
exit 1
fi
echo "Tenant credential preflight succeeded"
packages="$E2E_LIVE_PACKAGES"
if [ -z "$packages" ]; then
echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
@@ -363,7 +467,7 @@ jobs:
echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report
if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
if: ${{ !cancelled() }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
@@ -416,7 +520,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 +540,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.
# Legitimately skipped jobs (deadcode on push, e2e-live when not
# needed or on a 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,89 @@
All notable changes to this project will be documented in this file.
## [v1.0.72] - 2026-07-17
### Features
- **slides**: lint table out of canvas
- **slides**: report resolved table size mismatches
- **approval**: support approval event consumption (#1924)
### Bug Fixes
- **vc**: don't fail +detail for in-progress meetings (#1930)
- stabilize drive delete E2E terminal-state checks (#1939)
### Documentation
- **slides**: document table dimensions
- document base field default values (#1500)
- **sheets**: use English placeholder in table-get guidance (#1936)
### Tests
- stabilize live e2e auth retries (#1904)
- use tri-state wiki node identity in delete verification (#1931)
- fix drive cover download retries (#1934)
## [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
- add minutes permission application shortcut (#1876)
- **drive**: support apps in list comments (#1877)
- slide style
- edit ppt template
- **slides**: add sxsd validation to slides lint
- **slides**: validate iconpark icon types in slides lint
- **slides**: lint before create
- **apps**: add automation trigger commands for Miaoda (#1886)
### Bug Fixes
- unify dry-run output contract (#1870)
- **skills**: align skill guidance with the typed error contract (#1786)
- **slides**: limit slides screenshot page requests
- **slides**: detect lark slides text overflow overlap
- **vc**: align meeting query scopes by identity (#1850)
### Documentation
- clarify task search relevance filters (#1884)
- surface minutes permission application in skill description (#1890)
- clarify okr progress children (#1861)
- **slides**: prefer slides xml-get shortcut
- **calendar**: document setting meeting owner via full API (#1903)
### Refactoring
- **slides**: streamline create workflow and validate SML namespaces
### Misc
- **slides**: address PR review feedback
## [v1.0.69] - 2026-07-13
### Features
@@ -1469,6 +1552,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.72]: https://github.com/larksuite/cli/releases/tag/v1.0.72
[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
[v1.0.67]: https://github.com/larksuite/cli/releases/tag/v1.0.67

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
@@ -51,7 +51,7 @@ script-test:
bash scripts/resolve-changed-from.test.sh
bash scripts/ci-workflow.test.sh
bash scripts/semantic-review-workflow.test.sh
$(NODE) --test scripts/e2e_domains.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
$(NODE) --test scripts/e2e_domains.test.js scripts/fetch_e2e_tat.test.js scripts/semantic-review-verify-artifact.test.js scripts/pr-quality-summary.test.js scripts/semantic-review-publish.test.js scripts/ci-quality-summary-publish.test.js
# ./extension/... keeps the public plugin SDK in the default test matrix.
unit-test: fetch_meta
@@ -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

@@ -17,6 +17,8 @@ import (
func TestEventLookup_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {
@@ -36,6 +38,8 @@ func TestRunList_TextOutput(t *testing.T) {
out := stdout.String()
for _, want := range []string{
"KEY", "AUTH", "PARAMS", "DESCRIPTION",
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"im.message.receive_v1",
"im.message.message_read_v1",
"task.task.update_user_access_v2",
@@ -90,6 +94,8 @@ func TestRunList_JSONOutput(t *testing.T) {
t.Fatal("event list JSON missing task.task.update_user_access_v2")
}
for _, want := range []string{
"approval.instance.status_changed_v4",
"approval.task.status_changed_v4",
"vc.meeting.participant_meeting_started_v1",
"vc.meeting.participant_meeting_joined_v1",
} {

View File

@@ -19,6 +19,29 @@ import (
_ "github.com/larksuite/cli/events"
)
type approvalSchemaJSONPayload struct {
JQRootPath string `json:"jq_root_path"`
AuthTypes []string `json:"auth_types"`
Scopes []string `json:"scopes"`
Params []approvalSchemaJSONParam `json:"params"`
ResolvedOutputSchema approvalSchemaJSONResolvedSchema `json:"resolved_output_schema"`
}
type approvalSchemaJSONParam struct {
Name string `json:"name"`
Type string `json:"type"`
Required bool `json:"required"`
SubscriptionKey bool `json:"subscription_key"`
}
type approvalSchemaJSONResolvedSchema struct {
Properties map[string]approvalSchemaJSONProperty `json:"properties"`
}
type approvalSchemaJSONProperty struct {
Format string `json:"format"`
}
func TestRunSchema_ProcessedKey_Text(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
@@ -158,6 +181,60 @@ func TestRunSchema_TaskUpdateUserAccessJSON(t *testing.T) {
}
}
func TestRunSchema_ApprovalStatusChangedJSON(t *testing.T) {
tests := []struct {
key string
scope string
}{
{"approval.instance.status_changed_v4", "approval:instance:read"},
{"approval.task.status_changed_v4", "approval:task:read"},
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{AppID: "test"})
if err := runSchema(f, tc.key, true); err != nil {
t.Fatalf("runSchema json: %v", err)
}
var payload approvalSchemaJSONPayload
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatalf("output is not valid JSON: %v\n%s", err, stdout.String())
}
if payload.JQRootPath != "." {
t.Errorf("jq_root_path = %v, want .", payload.JQRootPath)
}
if got := payload.AuthTypes; !reflect.DeepEqual(got, []string{"user"}) {
t.Errorf("auth_types = %#v, want user", got)
}
if got := payload.Scopes; !reflect.DeepEqual(got, []string{tc.scope}) {
t.Errorf("scopes = %#v, want %s", got, tc.scope)
}
if len(payload.Params) != 1 {
t.Fatalf("params = %#v, want one subscription_type param", payload.Params)
}
param := payload.Params[0]
if param.Name != "subscription_type" || param.Type != "multi" || param.Required || param.SubscriptionKey {
t.Fatalf("subscription_type param = %#v, want optional multi non-subscription-key param", param)
}
props := payload.ResolvedOutputSchema.Properties
for _, field := range []string{"type", "event_id", "timestamp", "approval_code", "instance_code", "status", "operate_time"} {
if _, ok := props[field]; !ok {
t.Errorf("approval schema missing flat field %q: %+v", field, props)
}
}
if _, ok := props["event"]; ok {
t.Errorf("approval Custom schema should be flat, got envelope field event: %+v", props)
}
if got := props["operate_time"].Format; got != "timestamp_ms" {
t.Errorf("operate_time format = %v, want timestamp_ms", got)
}
})
}
}
func TestRunSchema_JSONOutput_VCMeetingLifecycleKeys(t *testing.T) {
for _, key := range []string{
"vc.meeting.participant_meeting_started_v1",

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
)
type approvalEventType string
type approvalSubscriptionPath string
type approvalSubscriptionConfig struct {
eventType approvalEventType
subscribePath approvalSubscriptionPath
}
func approvalSubscriptionPreConsume(cfg approvalSubscriptionConfig) func(context.Context, event.APIClient, map[string]string) (func() error, error) {
return func(ctx context.Context, rt event.APIClient, params map[string]string) (func() error, error) {
if rt == nil {
return nil, errs.NewInternalError(errs.SubtypeUnknown,
"runtime API client is required for pre-consume subscription")
}
eventType := string(cfg.eventType)
subscribePath := string(cfg.subscribePath)
subscriptionTypes, err := approvalSubscriptionTypes(eventType, params)
if err != nil {
return nil, err
}
registered := make([]string, 0, len(subscriptionTypes))
for _, subscriptionType := range subscriptionTypes {
body := map[string]string{"subscription_type": subscriptionType}
if _, err := rt.CallAPI(ctx, "POST", subscribePath, body); err != nil {
return nil, approvalSubscriptionRegistrationError(eventType, registered, subscriptionType, err)
}
registered = append(registered, subscriptionType)
}
// Approval subscriptions are durable user-auth relations. Consuming events
// should not cancel that relation when this local process exits.
return nil, nil
}
}
func approvalSubscriptionTypes(eventType string, params map[string]string) ([]string, error) {
raw := strings.TrimSpace(params["subscription_type"])
if raw == "" {
return append([]string(nil), approvalAllSubscriptionTypes...), nil
}
values, err := parseApprovalSubscriptionTypeValues(raw)
if err != nil {
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
}
selected := make(map[string]bool, len(values))
for _, value := range values {
value = strings.TrimSpace(value)
switch value {
case approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged:
selected[value] = true
default:
return nil, invalidApprovalSubscriptionTypeError(eventType, value)
}
}
result := make([]string, 0, len(selected))
for _, value := range approvalAllSubscriptionTypes {
if selected[value] {
result = append(result, value)
}
}
if len(result) == 0 {
return nil, invalidApprovalSubscriptionTypeError(eventType, raw)
}
return result, nil
}
func parseApprovalSubscriptionTypeValues(raw string) ([]string, error) {
if strings.HasPrefix(raw, "[") {
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, err
}
return values, nil
}
return strings.Split(raw, ","), nil
}
func approvalSubscriptionRegistrationError(eventType string, registered []string, failed string, err error) error {
if err == nil {
return nil
}
msg := fmt.Sprintf(
"approval subscription pre-consume failed for EventKey %s: failed subscription_type %s",
eventType,
failed,
)
hint := fmt.Sprintf(
"no approval subscription relation was registered for EventKey %s; fix the cause and retry",
eventType,
)
if len(registered) > 0 {
msg = fmt.Sprintf(
"approval subscription pre-consume partially completed for EventKey %s: registered subscription_type(s) [%s], failed subscription_type %s",
eventType,
strings.Join(registered, ", "),
failed,
)
hint = fmt.Sprintf(
"server-side approval subscription relation(s) already registered for EventKey %s: %s; after fixing the cause, retry with --param subscription_type=%s to register the failed relation",
eventType,
strings.Join(registered, ", "),
failed,
)
}
if p, ok := errs.ProblemOf(err); ok {
if upstream := strings.TrimSpace(p.Message); upstream != "" {
p.Message = msg + ": " + upstream
} else {
p.Message = msg
}
if upstreamHint := strings.TrimSpace(p.Hint); upstreamHint != "" {
p.Hint = upstreamHint + "\n" + hint
} else {
p.Hint = hint
}
return err
}
return errs.NewInternalError(errs.SubtypeSDKError, "%s: %v", msg, err).
WithHint("%s", hint).
WithCause(err)
}
func invalidApprovalSubscriptionTypeError(eventType, value string) error {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid subscription_type for EventKey %s: %q", eventType, value).
WithParam("--param").
WithHint("omit subscription_type to register both approval subscription relations, or pass --param subscription_type=%s, --param subscription_type=%s, or --param subscription_type=%s,%s; run `lark-cli event schema %s` for details",
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
eventType)
}

179
events/approval/register.go Normal file
View File

@@ -0,0 +1,179 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package approval registers Approval-domain EventKeys.
package approval
import (
"context"
"encoding/json"
"reflect"
"github.com/larksuite/cli/internal/event"
)
const (
eventTypeApprovalInstanceStatusChangedV4 = "approval.instance.status_changed_v4"
eventTypeApprovalTaskStatusChangedV4 = "approval.task.status_changed_v4"
pathApprovalInstancesSubscription = "/open-apis/approval/v4/instances/subscription"
pathApprovalTasksSubscription = "/open-apis/approval/v4/tasks/subscription"
approvalSubscriptionTypeInvolved = "INVOLVED_APPROVAL"
approvalSubscriptionTypeManaged = "MANAGED_APPROVAL"
)
var approvalAllSubscriptionTypes = []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
}
// Keys returns all Approval-domain EventKey definitions.
func Keys() []event.KeyDefinition {
return []event.KeyDefinition{
{
Key: eventTypeApprovalInstanceStatusChangedV4,
DisplayName: "Approval instance status changed",
Description: "Triggered after an approval instance status becomes visible to the requester or approval participants",
EventType: eventTypeApprovalInstanceStatusChangedV4,
Params: approvalSubscriptionParams(),
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{})},
},
Process: processApprovalInstanceStatusChanged,
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
}),
Scopes: []string{"approval:instance:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeApprovalInstanceStatusChangedV4},
},
{
Key: eventTypeApprovalTaskStatusChangedV4,
DisplayName: "Approval task status changed",
Description: "Triggered after an approval task status becomes visible to the requester or task approver",
EventType: eventTypeApprovalTaskStatusChangedV4,
Params: approvalSubscriptionParams(),
Schema: event.SchemaDef{
Custom: &event.SchemaSpec{Type: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{})},
},
Process: processApprovalTaskStatusChanged,
PreConsume: approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
}),
Scopes: []string{"approval:task:read"},
AuthTypes: []string{
"user",
},
RequiredConsoleEvents: []string{eventTypeApprovalTaskStatusChangedV4},
},
}
}
func approvalSubscriptionParams() []event.ParamDef {
return []event.ParamDef{
{
Name: "subscription_type",
Type: event.ParamMulti,
Description: "Approval subscription relation type(s) to register for the current authorized user. Omit to register both involved and managed approval relations.",
Values: []event.ParamValue{
{
Value: approvalSubscriptionTypeInvolved,
Desc: "Receive events where the current user is the approval requester or approver.",
},
{
Value: approvalSubscriptionTypeManaged,
Desc: "Receive events under approval definitions managed by the current user.",
},
},
},
}
}
func processApprovalInstanceStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
if raw == nil {
return nil, nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
ExternalID string `json:"external_id"`
Status string `json:"status"`
OperateTime string `json:"operate_time"`
StartUser *ApprovalUserID `json:"start_user"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
out := &ApprovalInstanceStatusChangedV4Output{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
ExternalID: envelope.Event.ExternalID,
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
StartUser: envelope.Event.StartUser,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}
func processApprovalTaskStatusChanged(_ context.Context, _ event.APIClient, raw *event.RawEvent, _ map[string]string) (json.RawMessage, error) {
if raw == nil {
return nil, nil
}
var envelope struct {
Header struct {
EventID string `json:"event_id"`
EventType string `json:"event_type"`
CreateTime string `json:"create_time"`
} `json:"header"`
Event struct {
ApprovalCode string `json:"approval_code"`
InstanceCode string `json:"instance_code"`
TaskID string `json:"task_id"`
ExternalID string `json:"external_id"`
TaskExternalID string `json:"task_external_id"`
AssignedUser *ApprovalUserID `json:"assigned_user"`
Status string `json:"status"`
OperateTime string `json:"operate_time"`
} `json:"event"`
}
if err := json.Unmarshal(raw.Payload, &envelope); err != nil {
return raw.Payload, nil //nolint:nilerr // passthrough on malformed payload so consumers still see the event
}
out := &ApprovalTaskStatusChangedV4Output{
Type: envelope.Header.EventType,
EventID: envelope.Header.EventID,
Timestamp: envelope.Header.CreateTime,
ApprovalCode: envelope.Event.ApprovalCode,
InstanceCode: envelope.Event.InstanceCode,
TaskID: envelope.Event.TaskID,
ExternalID: envelope.Event.ExternalID,
TaskExternalID: envelope.Event.TaskExternalID,
AssignedUser: envelope.Event.AssignedUser,
Status: envelope.Event.Status,
OperateTime: envelope.Event.OperateTime,
}
if out.Type == "" {
out.Type = raw.EventType
}
return json.Marshal(out)
}

View File

@@ -0,0 +1,654 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/event"
"github.com/larksuite/cli/internal/event/schemas"
)
type recordedCall struct {
method string
path string
body interface{}
}
type fakeAPIClient struct {
calls []recordedCall
err error
errOnCall int
}
func (f *fakeAPIClient) CallAPI(_ context.Context, method, path string, body interface{}) (json.RawMessage, error) {
f.calls = append(f.calls, recordedCall{method: method, path: path, body: body})
if f.err != nil && (f.errOnCall == 0 || f.errOnCall == len(f.calls)) {
return nil, f.err
}
return json.RawMessage(`{}`), nil
}
func TestKeysApprovalMetadata(t *testing.T) {
keys := Keys()
if len(keys) != 2 {
t.Fatalf("len(Keys()) = %d, want 2", len(keys))
}
tests := []struct {
key string
scope string
schemaType reflect.Type
subscribe string
}{
{
key: eventTypeApprovalInstanceStatusChangedV4,
scope: "approval:instance:read",
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
subscribe: pathApprovalInstancesSubscription,
},
{
key: eventTypeApprovalTaskStatusChangedV4,
scope: "approval:task:read",
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
subscribe: pathApprovalTasksSubscription,
},
}
byKey := make(map[string]event.KeyDefinition, len(keys))
for _, def := range keys {
byKey[def.Key] = def
}
for _, tc := range tests {
t.Run(tc.key, func(t *testing.T) {
def, ok := byKey[tc.key]
if !ok {
t.Fatalf("missing key %s", tc.key)
}
if def.EventType != tc.key {
t.Errorf("EventType = %q, want %q", def.EventType, tc.key)
}
if def.Schema.Custom == nil || def.Schema.Custom.Type != tc.schemaType {
t.Fatalf("Custom schema Type = %v, want %v", def.Schema.Custom, tc.schemaType)
}
if def.Schema.Native != nil {
t.Fatal("approval events must use Custom schema while SDK event types are not exported")
}
if def.Process == nil {
t.Fatal("Process must flatten raw V2 envelopes")
}
if def.PreConsume == nil {
t.Fatal("PreConsume must subscribe approval user-auth events")
}
if !reflect.DeepEqual(def.Scopes, []string{tc.scope}) {
t.Errorf("Scopes = %#v, want %q", def.Scopes, tc.scope)
}
if !reflect.DeepEqual(def.AuthTypes, []string{"user"}) {
t.Errorf("AuthTypes = %#v, want user", def.AuthTypes)
}
if !reflect.DeepEqual(def.RequiredConsoleEvents, []string{tc.key}) {
t.Errorf("RequiredConsoleEvents = %#v, want %q", def.RequiredConsoleEvents, tc.key)
}
assertSubscriptionParam(t, def.Params)
})
}
}
func assertSubscriptionParam(t *testing.T, params []event.ParamDef) {
t.Helper()
if len(params) != 1 {
t.Fatalf("len(params) = %d, want 1", len(params))
}
p := params[0]
if p.Name != "subscription_type" || p.Type != event.ParamMulti || p.Required || p.SubscriptionKey {
t.Fatalf("subscription_type param = %+v, want optional multi non-subscription-key param", p)
}
got := map[string]string{}
for _, v := range p.Values {
got[v.Value] = v.Desc
}
for _, want := range []string{approvalSubscriptionTypeInvolved, approvalSubscriptionTypeManaged} {
if got[want] == "" {
t.Errorf("subscription_type value %q missing or empty desc; values=%+v", want, p.Values)
}
}
}
type reflectedApprovalSchema struct {
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
}
type reflectedApprovalSchemaProperty struct {
Format string `json:"format"`
Enum []string `json:"enum"`
Properties map[string]reflectedApprovalSchemaProperty `json:"properties"`
}
func TestApprovalSchemasAnnotations(t *testing.T) {
tests := []struct {
name string
schemaType reflect.Type
eventType string
statusValues []string
userField string
}{
{
name: "instance",
schemaType: reflect.TypeOf(ApprovalInstanceStatusChangedV4Output{}),
eventType: eventTypeApprovalInstanceStatusChangedV4,
statusValues: []string{"PENDING", "APPROVED", "REJECTED", "CANCELED", "DELETED", "REVERTED", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
userField: "start_user",
},
{
name: "task",
schemaType: reflect.TypeOf(ApprovalTaskStatusChangedV4Output{}),
eventType: eventTypeApprovalTaskStatusChangedV4,
statusValues: []string{"REVERTED", "PENDING", "APPROVED", "REJECTED", "TRANSFERRED", "ROLLBACK", "DONE", "OVERTIME_CLOSE", "OVERTIME_RECOVER"},
userField: "assigned_user",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var schema reflectedApprovalSchema
if err := json.Unmarshal(schemas.FromType(tc.schemaType), &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
props := schema.Properties
eventTypeEnum := props["type"].Enum
if len(eventTypeEnum) != 1 || eventTypeEnum[0] != tc.eventType {
t.Fatalf("type enum = %v, want %s", eventTypeEnum, tc.eventType)
}
if got := props["timestamp"].Format; got != "timestamp_ms" {
t.Errorf("timestamp format = %v, want timestamp_ms", got)
}
assertEnumContains(t, props["status"].Enum, tc.statusValues)
if got := props["operate_time"].Format; got != "timestamp_ms" {
t.Errorf("event.operate_time format = %v, want timestamp_ms", got)
}
userProps := props[tc.userField].Properties
if got := userProps["open_id"].Format; got != "open_id" {
t.Errorf("%s.open_id format = %v, want open_id", tc.userField, got)
}
if got := userProps["union_id"].Format; got != "union_id" {
t.Errorf("%s.union_id format = %v, want union_id", tc.userField, got)
}
if got := userProps["user_id"].Format; got != "user_id" {
t.Errorf("%s.user_id format = %v, want user_id", tc.userField, got)
}
})
}
}
func assertEnumContains(t *testing.T, raw []string, wants []string) {
t.Helper()
got := make(map[string]bool, len(raw))
for _, v := range raw {
got[v] = true
}
for _, want := range wants {
if !got[want] {
t.Errorf("enum missing %q; enum=%v", want, raw)
}
}
}
func TestApprovalPreConsumeRegistersSubscriptionTypesWithoutCleanup(t *testing.T) {
tests := []struct {
name string
eventType string
subscribePath string
params map[string]string
wantTypes []string
}{
{
name: "instance omitted subscription_type registers both",
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
{
name: "task explicit single managed",
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
params: map[string]string{"subscription_type": approvalSubscriptionTypeManaged},
wantTypes: []string{approvalSubscriptionTypeManaged},
},
{
name: "task comma separated multi canonicalizes and deduplicates",
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
params: map[string]string{
"subscription_type": approvalSubscriptionTypeManaged + "," + approvalSubscriptionTypeInvolved + "," + approvalSubscriptionTypeManaged,
},
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
{
name: "instance json array multi",
eventType: eventTypeApprovalInstanceStatusChangedV4,
subscribePath: pathApprovalInstancesSubscription,
params: map[string]string{
"subscription_type": `["MANAGED_APPROVAL","INVOLVED_APPROVAL"]`,
},
wantTypes: []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: approvalEventType(tc.eventType),
subscribePath: approvalSubscriptionPath(tc.subscribePath),
})
rt := &fakeAPIClient{}
cleanup, err := pc(context.Background(), rt, tc.params)
if err != nil {
t.Fatalf("PreConsume returned error: %v", err)
}
if cleanup != nil {
t.Fatal("cleanup must be nil; approval consume must not unsubscribe on exit")
}
assertSubscriptionCalls(t, rt.calls, tc.subscribePath, tc.wantTypes)
})
}
}
func assertSubscriptionCalls(t *testing.T, got []recordedCall, wantPath string, wantTypes []string) {
t.Helper()
if len(got) != len(wantTypes) {
t.Fatalf("calls after pre-consume = %d, want %d; calls=%+v", len(got), len(wantTypes), got)
}
for i, wantType := range wantTypes {
assertCall(t, got[i], "POST", wantPath, map[string]string{"subscription_type": wantType})
}
}
func assertCall(t *testing.T, got recordedCall, wantMethod, wantPath string, wantBody interface{}) {
t.Helper()
if got.method != wantMethod {
t.Errorf("method = %q, want %q", got.method, wantMethod)
}
if got.path != wantPath {
t.Errorf("path = %q, want %q", got.path, wantPath)
}
if !reflect.DeepEqual(got.body, wantBody) {
t.Errorf("body = %#v, want %#v", got.body, wantBody)
}
}
func TestApprovalPreConsumeValidationErrors(t *testing.T) {
t.Run("nil runtime", func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
})
_, err := pc(context.Background(), nil, map[string]string{"subscription_type": approvalSubscriptionTypeInvolved})
if err == nil {
t.Fatal("expected nil runtime error")
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryInternal {
t.Fatalf("err = %T/%v, want typed internal error", err, err)
}
})
for _, raw := range []string{"BAD", "[]", `["INVOLVED_APPROVAL",3]`} {
t.Run("invalid subscription type "+raw, func(t *testing.T) {
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalInstanceStatusChangedV4,
})
cleanup, err := pc(context.Background(), &fakeAPIClient{}, map[string]string{"subscription_type": raw})
if err == nil {
t.Fatal("expected invalid subscription_type error")
}
if cleanup != nil {
t.Fatal("cleanup must be nil on validation error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("err = %T/%v, want *errs.ValidationError", err, err)
}
if ve.Subtype != errs.SubtypeInvalidArgument || ve.Param != "--param" {
t.Errorf("subtype/param = %s/%q, want invalid_argument/--param", ve.Subtype, ve.Param)
}
if ve.Hint == "" {
t.Error("invalid subscription_type should carry a hint")
}
})
}
t.Run("partial registration failure reports registered and failed relation types", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "approval subscription API failed")
rt := &fakeAPIClient{err: upstream, errOnCall: 2}
pc := approvalSubscriptionPreConsume(approvalSubscriptionConfig{
eventType: eventTypeApprovalTaskStatusChangedV4,
subscribePath: pathApprovalTasksSubscription,
})
cleanup, err := pc(context.Background(), rt, map[string]string{})
if err == nil {
t.Fatal("expected partial registration error")
}
if cleanup != nil {
t.Fatal("cleanup must be nil on registration error")
}
assertSubscriptionCalls(t, rt.calls, pathApprovalTasksSubscription, []string{
approvalSubscriptionTypeInvolved,
approvalSubscriptionTypeManaged,
})
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if p.Category != errs.CategoryAPI || p.Subtype != errs.SubtypeServerError {
t.Fatalf("category/subtype = %s/%s, want api/server_error", p.Category, p.Subtype)
}
for _, want := range []string{
"registered subscription_type(s) [INVOLVED_APPROVAL]",
"failed subscription_type MANAGED_APPROVAL",
} {
if !strings.Contains(p.Message, want) {
t.Errorf("partial error message missing %q: %q", want, p.Message)
}
}
for _, want := range []string{
"already registered",
"--param subscription_type=MANAGED_APPROVAL",
} {
if !strings.Contains(p.Hint, want) {
t.Errorf("partial error hint missing %q: %q", want, p.Hint)
}
}
})
}
func TestApprovalSubscriptionRegistrationErrorVariants(t *testing.T) {
t.Run("nil error", func(t *testing.T) {
if err := approvalSubscriptionRegistrationError(eventTypeApprovalTaskStatusChangedV4, nil, approvalSubscriptionTypeInvolved, nil); err != nil {
t.Fatalf("nil cause returned error: %v", err)
}
})
t.Run("typed error with existing hint and empty message", func(t *testing.T) {
upstream := errs.NewAPIError(errs.SubtypeServerError, "").WithHint("retry later")
err := approvalSubscriptionRegistrationError(
eventTypeApprovalTaskStatusChangedV4,
nil,
approvalSubscriptionTypeInvolved,
upstream,
)
if err != upstream {
t.Fatalf("typed error should be annotated in place; got %T/%v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if !strings.Contains(p.Message, "failed subscription_type INVOLVED_APPROVAL") {
t.Errorf("message missing failed relation: %q", p.Message)
}
for _, want := range []string{"retry later", "no approval subscription relation was registered"} {
if !strings.Contains(p.Hint, want) {
t.Errorf("hint missing %q: %q", want, p.Hint)
}
}
})
t.Run("untyped error is wrapped with retry context", func(t *testing.T) {
cause := errors.New("transport closed")
err := approvalSubscriptionRegistrationError(
eventTypeApprovalTaskStatusChangedV4,
nil,
approvalSubscriptionTypeInvolved,
cause,
)
if !errors.Is(err, cause) {
t.Fatalf("wrapped error should preserve cause; got %T/%v", err, err)
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("err = %T/%v, want typed error", err, err)
}
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeSDKError {
t.Fatalf("category/subtype = %s/%s, want internal/sdk_error", p.Category, p.Subtype)
}
if !strings.Contains(p.Hint, "no approval subscription relation was registered") {
t.Errorf("hint missing no-registration context: %q", p.Hint)
}
})
}
func TestProcessApprovalInstanceStatusChanged(t *testing.T) {
out := runApprovalInstanceStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_instance_001",
"event_type": "approval.instance.status_changed_v4",
"create_time": "1710000000000"
},
"event": {
"approval_code": "approval_code_001",
"instance_code": "instance_code_001",
"external_id": "external_001",
"status": "PENDING",
"operate_time": "1666079207003",
"start_user": {
"open_id": "ou_start",
"union_id": "on_start",
"user_id": "user_start"
}
}
}`)
if out.Type != eventTypeApprovalInstanceStatusChangedV4 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalInstanceStatusChangedV4)
}
if out.EventID != "evt_approval_instance_001" || out.Timestamp != "1710000000000" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.ApprovalCode != "approval_code_001" || out.InstanceCode != "instance_code_001" {
t.Errorf("approval/instance code = %q/%q", out.ApprovalCode, out.InstanceCode)
}
if out.ExternalID != "external_001" || out.Status != "PENDING" || out.OperateTime != "1666079207003" {
t.Errorf("external/status/operate_time = %q/%q/%q", out.ExternalID, out.Status, out.OperateTime)
}
if out.StartUser == nil || out.StartUser.OpenID != "ou_start" || out.StartUser.UnionID != "on_start" || out.StartUser.UserID != "user_start" {
t.Fatalf("StartUser = %+v, want full user ids", out.StartUser)
}
}
func TestProcessApprovalTaskStatusChanged(t *testing.T) {
out := runApprovalTaskStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_task_001",
"event_type": "approval.task.status_changed_v4",
"create_time": "1710000000001"
},
"event": {
"approval_code": "approval_code_002",
"instance_code": "instance_code_002",
"task_id": "task_001",
"external_id": "external_002",
"task_external_id": "task_external_001",
"status": "APPROVED",
"operate_time": "1666079207004",
"assigned_user": {
"open_id": "ou_assignee",
"union_id": "on_assignee",
"user_id": "user_assignee"
}
}
}`)
if out.Type != eventTypeApprovalTaskStatusChangedV4 {
t.Errorf("Type = %q, want %q", out.Type, eventTypeApprovalTaskStatusChangedV4)
}
if out.EventID != "evt_approval_task_001" || out.Timestamp != "1710000000001" {
t.Errorf("EventID/Timestamp = %q/%q", out.EventID, out.Timestamp)
}
if out.ApprovalCode != "approval_code_002" || out.InstanceCode != "instance_code_002" || out.TaskID != "task_001" {
t.Errorf("approval/instance/task = %q/%q/%q", out.ApprovalCode, out.InstanceCode, out.TaskID)
}
if out.ExternalID != "external_002" || out.TaskExternalID != "task_external_001" || out.Status != "APPROVED" || out.OperateTime != "1666079207004" {
t.Errorf("external/task_external/status/operate_time = %q/%q/%q/%q", out.ExternalID, out.TaskExternalID, out.Status, out.OperateTime)
}
if out.AssignedUser == nil || out.AssignedUser.OpenID != "ou_assignee" || out.AssignedUser.UnionID != "on_assignee" || out.AssignedUser.UserID != "user_assignee" {
t.Fatalf("AssignedUser = %+v, want full user ids", out.AssignedUser)
}
}
func TestProcessApprovalStatusChangedUsesRawEventTypeFallback(t *testing.T) {
instance := runApprovalInstanceStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_instance_fallback",
"create_time": "1710000000002"
},
"event": {
"approval_code": "approval_code_fallback",
"instance_code": "instance_code_fallback",
"status": "APPROVED",
"operate_time": "1666079207005"
}
}`)
if instance.Type != eventTypeApprovalInstanceStatusChangedV4 {
t.Errorf("instance Type fallback = %q, want %q", instance.Type, eventTypeApprovalInstanceStatusChangedV4)
}
task := runApprovalTaskStatusChanged(t, `{
"schema": "2.0",
"header": {
"event_id": "evt_approval_task_fallback",
"create_time": "1710000000003"
},
"event": {
"approval_code": "approval_code_fallback",
"instance_code": "instance_code_fallback",
"task_id": "task_fallback",
"status": "DONE",
"operate_time": "1666079207006"
}
}`)
if task.Type != eventTypeApprovalTaskStatusChangedV4 {
t.Errorf("task Type fallback = %q, want %q", task.Type, eventTypeApprovalTaskStatusChangedV4)
}
}
func TestProcessApprovalStatusChangedMalformedPayloadPassthrough(t *testing.T) {
for _, tc := range []struct {
name string
eventType string
process event.ProcessFunc
}{
{"instance", eventTypeApprovalInstanceStatusChangedV4, processApprovalInstanceStatusChanged},
{"task", eventTypeApprovalTaskStatusChangedV4, processApprovalTaskStatusChanged},
} {
t.Run(tc.name, func(t *testing.T) {
raw := &event.RawEvent{
EventType: tc.eventType,
Payload: json.RawMessage(`not json`),
Timestamp: time.Now(),
}
got, err := tc.process(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process should swallow parse errors, got %v", err)
}
if string(got) != "not json" {
t.Errorf("malformed fallback output = %q, want original bytes", string(got))
}
})
}
}
func TestProcessApprovalStatusChangedNilRaw(t *testing.T) {
for _, tc := range []struct {
name string
process event.ProcessFunc
}{
{"instance", processApprovalInstanceStatusChanged},
{"task", processApprovalTaskStatusChanged},
} {
t.Run(tc.name, func(t *testing.T) {
got, err := tc.process(context.Background(), nil, nil, nil)
if err != nil {
t.Fatalf("Process nil raw returned error: %v", err)
}
if got != nil {
t.Fatalf("Process nil raw output = %s, want nil", string(got))
}
})
}
}
func runApprovalInstanceStatusChanged(t *testing.T, payload string) ApprovalInstanceStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeApprovalInstanceStatusChangedV4,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processApprovalInstanceStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
var out ApprovalInstanceStatusChangedV4Output
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid instance JSON: %v\nraw=%s", err, string(got))
}
return out
}
func runApprovalTaskStatusChanged(t *testing.T, payload string) ApprovalTaskStatusChangedV4Output {
t.Helper()
raw := &event.RawEvent{
EventType: eventTypeApprovalTaskStatusChangedV4,
Payload: json.RawMessage(payload),
Timestamp: time.Now(),
}
got, err := processApprovalTaskStatusChanged(context.Background(), nil, raw, nil)
if err != nil {
t.Fatalf("Process returned error: %v", err)
}
var out ApprovalTaskStatusChangedV4Output
if err := json.Unmarshal(got, &out); err != nil {
t.Fatalf("Process output is not valid task JSON: %v\nraw=%s", err, string(got))
}
return out
}
func TestApprovalKeysRegisterCleanly(t *testing.T) {
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
event.UnregisterKeyForTest(key)
t.Cleanup(func() { event.UnregisterKeyForTest(key) })
}
for _, def := range Keys() {
event.RegisterKey(def)
}
for _, key := range []string{eventTypeApprovalInstanceStatusChangedV4, eventTypeApprovalTaskStatusChangedV4} {
if _, ok := event.Lookup(key); !ok {
t.Fatalf("event.Lookup(%q) not registered", key)
}
}
}
var _ event.APIClient = (*fakeAPIClient)(nil)

42
events/approval/types.go Normal file
View File

@@ -0,0 +1,42 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package approval
// ApprovalUserID identifies a user in the three Lark ID formats included by
// approval status-change events.
type ApprovalUserID struct {
OpenID string `json:"open_id,omitempty" desc:"User open_id; prefixed with ou_" kind:"open_id"`
UnionID string `json:"union_id,omitempty" desc:"User union_id" kind:"union_id"`
UserID string `json:"user_id,omitempty" desc:"User id within the tenant" kind:"user_id"`
}
// ApprovalInstanceStatusChangedV4Output is the flattened shape for
// approval.instance.status_changed_v4.
type ApprovalInstanceStatusChangedV4Output struct {
Type string `json:"type" desc:"Event type; always approval.instance.status_changed_v4" enum:"approval.instance.status_changed_v4"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval instance id; present only for third-party approvals"`
Status string `json:"status,omitempty" desc:"Approval instance status" enum:"PENDING,APPROVED,REJECTED,CANCELED,DELETED,REVERTED,OVERTIME_CLOSE,OVERTIME_RECOVER"`
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
StartUser *ApprovalUserID `json:"start_user,omitempty" desc:"Approval instance starter; omitted when unavailable"`
}
// ApprovalTaskStatusChangedV4Output is the flattened shape for
// approval.task.status_changed_v4.
type ApprovalTaskStatusChangedV4Output struct {
Type string `json:"type" desc:"Event type; always approval.task.status_changed_v4" enum:"approval.task.status_changed_v4"`
EventID string `json:"event_id,omitempty" desc:"Globally unique event ID; safe for deduplication"`
Timestamp string `json:"timestamp,omitempty" desc:"Event delivery time (ms timestamp string); taken from header.create_time when present" kind:"timestamp_ms"`
ApprovalCode string `json:"approval_code,omitempty" desc:"Approval definition code; not a subscription dimension"`
InstanceCode string `json:"instance_code,omitempty" desc:"Approval instance code"`
TaskID string `json:"task_id,omitempty" desc:"Approval task id"`
ExternalID string `json:"external_id,omitempty" desc:"Third-party approval external id; present only for third-party approvals"`
TaskExternalID string `json:"task_external_id,omitempty" desc:"Third-party approval task external id; present only when emitted by the upstream service"`
AssignedUser *ApprovalUserID `json:"assigned_user,omitempty" desc:"Task assignee or operator user ids; omitted for automatic flows without an operator"`
Status string `json:"status,omitempty" desc:"Approval task status" enum:"REVERTED,PENDING,APPROVED,REJECTED,TRANSFERRED,ROLLBACK,DONE,OVERTIME_CLOSE,OVERTIME_RECOVER"`
OperateTime string `json:"operate_time,omitempty" desc:"Status change time in milliseconds" kind:"timestamp_ms"`
}

View File

@@ -5,6 +5,7 @@
package events
import (
"github.com/larksuite/cli/events/approval"
"github.com/larksuite/cli/events/im"
"github.com/larksuite/cli/events/minutes"
"github.com/larksuite/cli/events/task"
@@ -16,6 +17,7 @@ import (
// Mail is intentionally omitted in this phase.
func init() {
all := [][]event.KeyDefinition{
approval.Keys(),
im.Keys(),
minutes.Keys(),
task.Keys(),

View File

@@ -4,6 +4,10 @@
package cmdutil
import (
"os"
"path/filepath"
"strings"
"github.com/larksuite/cli/errs"
)
@@ -14,12 +18,75 @@ import (
// with --yes.
//
// action identifies the operation for the agent (e.g. "mail +send",
// "drive.files.delete"). The envelope does not carry a pre-built retry
// command: agents already know their original invocation and only need to
// append --yes per the hint, which keeps the protocol free of shell-quoting
// pitfalls.
// "drive.files.delete"). When the original invocation can be re-run safely,
// the hint carries the complete retry command with --yes appended — eval
// traces show agents always self-heal by appending --yes, so handing them
// the exact line saves the reconstruction step. The retry line is omitted
// (falling back to the plain hint) when any argument reads stdin (a bare "-",
// as its own token or bundled onto a flag as --flag=-, whose piped data a
// bare re-run would not reproduce) or when the rendered command would be
// unreasonably long to echo back.
func RequireConfirmation(action string) error {
return errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action).
WithHint("add --yes to confirm")
err := errs.NewConfirmationRequiredError(errs.RiskHighRiskWrite, action,
"%s requires confirmation", action)
if retry := retryCommandWithYes(os.Args); retry != "" {
return err.WithHint("add --yes to confirm; re-run: %s", retry)
}
return err.WithHint("add --yes to confirm")
}
// retryCommandMaxLen caps the rendered retry command: past this, echoing the
// full invocation back (e.g. a +batch-update with a large inline JSON)
// costs more context than it saves.
const retryCommandMaxLen = 300
// retryCommandWithYes renders args as a shell-safe command line with --yes
// appended, or "" when a safe rendering isn't possible (see
// RequireConfirmation).
func retryCommandWithYes(args []string) string {
if len(args) == 0 {
return ""
}
parts := make([]string, 0, len(args)+1)
parts = append(parts, filepath.Base(args[0]))
for _, a := range args[1:] {
if argReadsStdin(a) {
return ""
}
parts = append(parts, shellQuoteArg(a))
}
parts = append(parts, "--yes")
line := strings.Join(parts, " ")
if len(line) > retryCommandMaxLen {
return ""
}
return line
}
// argReadsStdin reports whether an argument makes a flag read from stdin — the
// portable bare "-" value, whether passed as its own token (--flag -) or
// bundled onto the flag (--flag=- / -f=-). Piped stdin is one-shot data a bare
// re-run cannot reproduce, so any such argument suppresses the retry line.
func argReadsStdin(a string) bool {
if a == "-" {
return true
}
if strings.HasPrefix(a, "-") {
if i := strings.IndexByte(a, '='); i >= 0 && a[i+1:] == "-" {
return true
}
}
return false
}
// shellQuoteArg single-quotes an argument when it contains any character a
// POSIX shell could interpret, so the retry line is copy-paste safe.
func shellQuoteArg(s string) string {
if s == "" {
return "''"
}
if !strings.ContainsAny(s, " \t\n\"'\\$`!*?[](){}<>|&;#~") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}

View File

@@ -35,8 +35,11 @@ func TestRequireConfirmation_TypedShape(t *testing.T) {
if !strings.Contains(cre.Message, "drive +delete") || !strings.Contains(cre.Message, "requires confirmation") {
t.Errorf("Message = %q, want it to mention action and 'requires confirmation'", cre.Message)
}
if cre.Hint != "add --yes to confirm" {
t.Errorf("Hint = %q, want 'add --yes to confirm'", cre.Hint)
// The hint may additionally carry a re-run line composed from the live
// os.Args (environment-dependent under `go test`), but the add-yes
// contract always leads.
if !strings.HasPrefix(cre.Hint, "add --yes to confirm") {
t.Errorf("Hint = %q, want prefix 'add --yes to confirm'", cre.Hint)
}
if cre.Risk != errs.RiskHighRiskWrite {
t.Errorf("Risk = %q, want %q", cre.Risk, errs.RiskHighRiskWrite)
@@ -61,8 +64,8 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
t.Fatalf("unmarshal: %v", err)
}
// No fix_command field leaks into the envelope: the protocol avoids
// shell-quoting hazards by delegating retry to agent-side logic.
// No fix_command field leaks into the envelope: the retry line lives in
// the free-text hint only; the typed protocol stays action-only.
if _, has := back["fix_command"]; has {
t.Errorf("unexpected fix_command present in JSON: %s", raw)
}
@@ -78,3 +81,46 @@ func TestRequireConfirmation_JSONShape(t *testing.T) {
t.Errorf("unexpected upgraded_by present in JSON: %s", raw)
}
}
// TestRetryCommandWithYes pins the retry-line contract: shell-safe quoting,
// basename argv[0], and the two omission guards (stdin args, oversized
// commands).
func TestRetryCommandWithYes(t *testing.T) {
t.Run("quotes what needs quoting and appends --yes", func(t *testing.T) {
got := retryCommandWithYes([]string{
"/usr/local/bin/lark-cli", "sheets", "+cells-clear",
"--url", "https://x.feishu.cn/sheets/tok",
"--range", "A1:B2", "--sheet-name", "第 1 班",
})
want := `lark-cli sheets +cells-clear --url https://x.feishu.cn/sheets/tok --range A1:B2 --sheet-name '第 1 班' --yes`
if got != want {
t.Errorf("got %q, want %q", got, want)
}
})
t.Run("single quotes inside args survive", func(t *testing.T) {
got := retryCommandWithYes([]string{"lark-cli", "x", "--title", "it's"})
if !strings.Contains(got, `'it'\''s'`) {
t.Errorf("got %q", got)
}
})
t.Run("stdin arg omits the retry line", func(t *testing.T) {
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+batch-update", "--operations", "-"}); got != "" {
t.Errorf("stdin invocation must not render a retry line, got %q", got)
}
})
t.Run("bundled stdin flag omits the retry line", func(t *testing.T) {
// --flag=- reads stdin the same as --flag -; both must suppress the line.
if got := retryCommandWithYes([]string{"lark-cli", "sheets", "+cells-set", "--cells=-"}); got != "" {
t.Errorf("--flag=- stdin invocation must not render a retry line, got %q", got)
}
})
t.Run("oversized command omits the retry line", func(t *testing.T) {
if got := retryCommandWithYes([]string{"lark-cli", "x", "--operations", strings.Repeat("a", 400)}); got != "" {
t.Errorf("oversized invocation must not render a retry line, got %q", got)
}
})
}

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.69",
"version": "1.0.72",
"description": "The official CLI for Lark/Feishu open platform",
"bin": {
"lark-cli": "scripts/run.js"

View File

@@ -18,6 +18,11 @@ workflow_permissions="$(awk '
in_permissions && /^[^[:space:]]/ { exit }
in_permissions { print }
' "$workflow")"
workflow_concurrency="$(awk '
/^concurrency:/ { in_concurrency = 1; print; next }
in_concurrency && /^[^[:space:]]/ { exit }
in_concurrency { print }
' "$workflow")"
fast_gate_section="$(job_section fast-gate)"
unit_test_section="$(job_section unit-test)"
lint_section="$(awk '
@@ -46,6 +51,27 @@ results_section="$(awk '
in_job { print }
' "$workflow")"
fork_safe_guard="github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork"
live_job_condition="always() && ($fork_safe_guard) && needs.unit-test.result == 'success' && needs.lint.result == 'success' && needs.script-test.result == 'success' && needs.deterministic-gate.result == 'success' && needs.e2e-dry-run.result == 'success' && (needs.e2e-dry-run.outputs.mode == 'full' || needs.e2e-dry-run.outputs.mode == 'subset') && needs.e2e-dry-run.outputs.live_packages != ''"
if ! grep -Fq "run-name: \${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}" "$workflow"; then
echo "CI should expose a stable PR generation while preserving default push and manual run titles" >&2
exit 1
fi
if ! grep -Fq "RUN_GENERATION: \${{ github.event_name == 'pull_request' && format('CI / {0}', github.event.pull_request.number) || '' }}" <<<"$section"; then
echo "the supersession generation should match the PR-only run name" >&2
exit 1
fi
if ! grep -Fq 'group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}' <<<"$workflow_concurrency"; then
echo "CI should deduplicate runs for the same pull request without grouping push or manual runs" >&2
exit 1
fi
if ! grep -Fq "cancel-in-progress: \${{ github.event_name == 'pull_request' }}" <<<"$workflow_concurrency"; then
echo "CI should cancel superseded pull request runs but preserve push and manual runs" >&2
exit 1
fi
for denied_permission in "checks: write" "pull-requests: write" "issues: write"; do
if grep -Eq "^[[:space:]]*${denied_permission}$" <<<"$workflow_permissions"; then
@@ -210,8 +236,84 @@ if ! grep -Fq "deterministic-gate" <<<"$results_section"; then
exit 1
fi
if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
echo "e2e-live should run on push and same-repository pull_request, but skip fork pull_request"
if ! grep -Fq "if: \${{ $live_job_condition }}" <<<"$section"; then
echo "e2e-live should preserve active cleanup while requiring a successful non-skip dry run and excluding fork pull requests"
exit 1
fi
if ! grep -Fq "needs: [unit-test, lint, script-test, deterministic-gate, e2e-dry-run]" <<<"$section"; then
echo "e2e-live should wait outside the exclusive queue until e2e-dry-run finishes"
exit 1
fi
if ! grep -Fq "timeout-minutes: 20" <<<"$dry_run_section"; then
echo "e2e-dry-run should bound the planning gate before live E2E" >&2
exit 1
fi
if ! grep -Fq "timeout-minutes: 30" <<<"$section"; then
echo "e2e-live should release the repository-wide slot after 30 minutes" >&2
exit 1
fi
if ! grep -Fq "group: lark-cli-e2e-live" <<<"$section"; then
echo "e2e-live should use one repository-wide execution slot" >&2
exit 1
fi
if ! grep -Fq "cancel-in-progress: false" <<<"$section"; then
echo "e2e-live should queue waiting runs instead of cancelling an active live test" >&2
exit 1
fi
if ! grep -Fq "queue: max" <<<"$section"; then
echo "e2e-live should preserve queued runs instead of replacing an existing pending run" >&2
exit 1
fi
if ! grep -Fq "actions: read" <<<"$section"; then
echo "e2e-live should use read-only Actions access for the supersession check" >&2
exit 1
fi
live_test_step="$(awk '
/^ - name: Run CLI E2E tests/ { in_step = 1 }
in_step { print }
in_step && /^ - name: Publish CLI E2E test report/ { exit }
' <<<"$section")"
if ! grep -Fq "if: \${{ always() && steps.build_cli.outcome == 'success' && steps.live_e2e_tat.outcome == 'success' }}" <<<"$live_test_step"; then
echo "the active live test step should survive ordinary workflow supersession only after setup succeeds" >&2
exit 1
fi
for required in \
'gh api "repos/$REPOSITORY/actions/runs/$RUN_ID"' \
'gh api --paginate -X GET "repos/$REPOSITORY/actions/workflows/$workflow_id/runs"' \
'-f event=pull_request -f branch="$GITHUB_HEAD_REF" -f per_page=100' \
'.head_repository.full_name == $repository and .display_title == $generation and .run_number > $run_number' \
'::error::Superseded before live E2E started' \
'exit 1'; do
if ! grep -Fq -- "$required" <<<"$live_test_step"; then
echo "the live startup check should fail closed before a superseded run starts live E2E: missing $required" >&2
exit 1
fi
done
if ! awk '
/if \[ -n "\$newer_runs" \]; then/ { superseded_state = 1; next }
superseded_state == 1 && /::error::Superseded before live E2E started/ { superseded_state = 2; next }
superseded_state == 2 && /^[[:space:]]+exit 1[[:space:]]*$/ { superseded_state = 3; next }
superseded_state > 0 && /^[[:space:]]+fi[[:space:]]*$/ {
if (superseded_state != 3) exit 2
superseded_closed = 1
superseded_state = 0
next
}
/go run gotest.tools\/gotestsum@/ { test_started = 1; if (!superseded_closed) exit 3 }
END { exit superseded_closed && test_started ? 0 : 1 }
' <<<"$live_test_step"; then
echo "a superseded live run must stop before gotestsum starts" >&2
exit 1
fi
@@ -222,6 +324,39 @@ if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
exit 1
fi
for output in \
'mode: ${{ steps.e2e_domains.outputs.mode }}' \
'reason: ${{ steps.e2e_domains.outputs.reason }}' \
'live_packages: ${{ steps.e2e_domains.outputs.live_packages }}'; do
if ! grep -Fq "$output" <<<"$dry_run_section"; then
echo "e2e-dry-run should publish $output for the live job" >&2
exit 1
fi
done
for validation_contract in \
'case "$E2E_MODE" in' \
'skip)' \
'[ -z "$E2E_LIVE_PACKAGES" ]' \
'full|subset)' \
'[ -n "$E2E_LIVE_PACKAGES" ]' \
'Invalid CLI E2E mode' \
'exit 1'; do
if ! grep -Fq "$validation_contract" <<<"$dry_run_section"; then
echo "e2e-dry-run should fail invalid domain output before live can be skipped: missing $validation_contract" >&2
exit 1
fi
done
if ! awk '
/- name: Validate CLI E2E domain outputs/ { validated = 1 }
/- name: Build lark-cli/ { exit validated ? 0 : 1 }
END { if (!validated) exit 1 }
' <<<"$dry_run_section"; then
echo "e2e-dry-run should validate domain outputs before building" >&2
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
exit 1
@@ -244,21 +379,21 @@ if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
! grep -Fq "id: e2e_domains" <<<"$section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
if grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should reuse e2e-dry-run outputs instead of resolving domains again"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
echo "e2e-live should use resolved live_packages instead of always running the full suite"
if ! grep -Fq "E2E_LIVE_PACKAGES: \${{ needs.e2e-dry-run.outputs.live_packages }}" <<<"$section"; then
echo "e2e-live should reuse live_packages resolved by e2e-dry-run"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
if ! grep -Fq "E2E_MODE: \${{ needs.e2e-dry-run.outputs.mode }}" <<<"$section" ||
! grep -Fq "E2E_REASON: \${{ needs.e2e-dry-run.outputs.reason }}" <<<"$section" ||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
echo "e2e-live should pass dynamic domain output through env before shell use"
echo "e2e-live should consume the exact mode and reason produced by e2e-dry-run"
exit 1
fi
@@ -272,16 +407,23 @@ if ! awk '
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should skip building lark-cli when domain mode is skip"
if grep -Fq "steps.e2e_domains.outputs" <<<"$section"; then
echo "e2e-live should not retain step-local domain outputs after adopting the dry-run job gate"
exit 1
fi
for step_name in "Build lark-cli" "Prepare shared live E2E tenant token"; do
live_setup_step="$(awk -v name="$step_name" '
$0 == " - name: " name { in_step = 1 }
in_step { print }
in_step && /^ - name:/ && $0 != " - name: " name { exit }
' <<<"$section")"
if grep -Eq '^ if:' <<<"$live_setup_step"; then
echo "e2e-live $step_name should run unconditionally after the non-skip job gate" >&2
exit 1
fi
done
if ! grep -Fq "permissions:" <<<"$section" ||
! grep -Fq "contents: read" <<<"$section" ||
! grep -Fq "checks: write" <<<"$section"; then
@@ -299,18 +441,88 @@ if grep -Fq "live_e2e_credentials" <<<"$section" || grep -Fq "configured=false"
exit 1
fi
if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET" <<<"$section"; then
echo "e2e-live should make missing bot credentials a visible configuration failure on eligible runs"
if ! grep -Fq "node scripts/fetch_e2e_tat.js" <<<"$section"; then
echo "e2e-live should fetch the tenant token via the dedicated script"
exit 1
fi
if grep -Fq "config init" <<<"$section"; then
echo "e2e-live should use env credentials instead of config init"
exit 1
fi
if ! grep -Fq "TEST_BOT1_APP_ID: \${{ secrets.TEST_BOT1_APP_ID }}" <<<"$section"; then
echo "e2e-live should keep the bot app id under a test-only job env name"
exit 1
fi
if awk '
/^ e2e-live:/ { in_job = 1; next }
in_job && /^ [A-Za-z0-9_-]+:/ { in_job = 0 }
in_job && /^ env:/ { in_env = 1; next }
in_env && /^ steps:/ { in_env = 0 }
in_env && /LARKSUITE_CLI_APP_ID:/ { found_standard_app_id = 1 }
END { exit found_standard_app_id ? 0 : 1 }
' "$workflow"; then
echo "e2e-live should not activate the env credential provider at job scope"
exit 1
fi
if ! grep -Fq "LARKSUITE_CLI_BRAND: feishu" <<<"$section"; then
echo "e2e-live should pin the env credential brand to feishu"
exit 1
fi
if awk '
/^ e2e-live:/ { in_job = 1; next }
in_job && /^ [A-Za-z0-9_-]+:/ { in_job = 0 }
in_job && /^ env:/ { in_env = 1; next }
in_env && /^ steps:/ { in_env = 0 }
in_env && /(SECRET|ACCESS_TOKEN):/ { found_sensitive = 1 }
END { exit found_sensitive ? 0 : 1 }
' "$workflow"; then
echo "e2e-live should not expose live E2E credentials through job-level env"
exit 1
fi
if ! awk '
/^ - name: Configure bot credentials/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
END { exit found ? 0 : 1 }
/^ - name: Prepare shared live E2E tenant token/ { in_step = 1 }
in_step && /id: live_e2e_tat/ { has_id = 1 }
in_step && /^ if:/ { has_if = 1 }
in_step && /LARKSUITE_CLI_APP_ID: \$\{\{ secrets\.TEST_BOT1_APP_ID \}\}/ { has_app_id = 1 }
in_step && /secrets\.TEST_BOT1_APP_SECRET/ { has_bot_credential = 1 }
in_step && /node scripts\/fetch_e2e_tat\.js/ { has_script = 1 }
in_step && /GITHUB_ENV/ { uses_github_env = 1 }
in_step && /^ - name:/ && !/Prepare shared live E2E tenant token/ { in_step = 0 }
END { exit has_id && !has_if && has_app_id && has_bot_credential && has_script && !uses_github_env ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should only configure bot credentials when domain mode is not skip"
echo "e2e-live should pass only a private tenant token file path through step output"
exit 1
fi
if ! awk '
/^ - name: Run CLI E2E tests/ { in_step = 1 }
in_step && /E2E_TENANT_AUTH_FILE: \$\{\{ steps\.live_e2e_tat\.outputs\.path \}\}/ { has_file = 1 }
in_step && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_user_credential = 1 }
in_step && /Missing shared live E2E tenant token file/ { checks_file = 1 }
in_step && /^ *export / && /TEST_TENANT_ACCESS_TOKEN/ && /E2E_TENANT_AUTH_FILE/ { exports_test_tat = 1 }
in_step && /^ *export / && /LARKSUITE_CLI_TENANT_ACCESS_TOKEN/ { exports_standard_tat = 1 }
in_step && /LARKSUITE_CLI_APP_ID="\$TEST_BOT1_APP_ID"/ { scopes_preflight_app_id = 1 }
in_step && /LARKSUITE_CLI_TENANT_ACCESS_TOKEN="\$TEST_TENANT_ACCESS_TOKEN"/ { scopes_preflight_tat = 1 }
in_step && /lark-cli whoami --as bot/ { has_preflight = 1 }
in_step && /Tenant credential preflight failed/ { checks_preflight = 1 }
in_step && /TEST_USER_ACCESS_TOKEN/ && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_user_env = 1 }
in_step && /LARKSUITE_CLI_USER_ACCESS_TOKEN/ && /secrets\.TEST_USER_ACCESS_TOKEN/ { has_global_user_env = 1 }
in_step && /trap / { has_trap = 1 }
in_step && /^ - name:/ && !/Run CLI E2E tests/ { in_step = 0 }
END { exit has_file && has_user_credential && checks_file && exports_test_tat && !exports_standard_tat && scopes_preflight_app_id && scopes_preflight_tat && has_preflight && checks_preflight && has_user_env && !has_global_user_env && !has_trap ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should expose live E2E credentials only inside the test shell step"
exit 1
fi
if grep -Fq 'if [ "$E2E_MODE" = "skip" ]' <<<"$section"; then
echo "e2e-live should not retain an unreachable step-level skip branch"
exit 1
fi
@@ -319,8 +531,8 @@ if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
exit 1
fi
if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled"
exit 1
fi
@@ -342,7 +554,7 @@ if grep -Fq '${{ secrets.CODECOV_TOKEN }}' <<<"$coverage_step" &&
fi
if grep -Fq '${{ secrets.' <<<"$section" &&
! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
! grep -Fq "$fork_safe_guard" <<<"$section"; then
echo "live E2E secrets should be available on push and same-repository pull_request, but not fork pull_request" >&2
exit 1
fi

164
scripts/fetch_e2e_tat.js Normal file
View File

@@ -0,0 +1,164 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Fetches a live E2E tenant access token (TAT) for the shared bot identity.
//
// Invoked from the e2e-live CI job. Exchanges the bot app id/secret for a
// tenant access token, writes the token to a private file under $RUNNER_TEMP,
// and emits the file path as a step output so the test step can read it once
// and then delete it.
//
// The secret arrives via environment variables; the OAuth parameter names are
// literal because this is a source code file (.js), so the quality gate's
// benign-code-credential exemption applies to the process.env references.
const fs = require("node:fs");
const http = require("node:http");
const https = require("node:https");
const path = require("node:path");
const { URL } = require("node:url");
const ENDPOINT = process.env.E2E_TAT_ENDPOINT || "https://accounts.feishu.cn/oauth/v3/token";
const MAX_ATTEMPTS = 4;
const RETRY_BASE_MS = parseInt(process.env.E2E_TAT_RETRY_BASE_MS || "1000", 10);
function requireEnv(name) {
const value = process.env[name];
if (!value) {
console.error(`::error::Missing required environment variable: ${name}`);
process.exit(1);
}
return value;
}
function postForm(url, body) {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const transport = parsed.protocol === "http:" ? http : https;
const req = transport.request(
parsed,
{
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": Buffer.byteLength(body),
},
timeout: 20000,
},
(resp) => {
const chunks = [];
let settled = false;
const rejectOnce = (error) => {
if (!settled) {
settled = true;
reject(error);
}
};
resp.on("data", (chunk) => chunks.push(chunk));
resp.on("aborted", () => rejectOnce(new Error("response aborted before completion")));
resp.on("error", rejectOnce);
resp.on("close", () => {
if (!resp.complete) {
rejectOnce(new Error("response closed before completion"));
}
});
resp.on("end", () => {
if (!resp.complete) {
rejectOnce(new Error("response ended before completion"));
return;
}
settled = true;
resolve({
status: resp.statusCode,
body: Buffer.concat(chunks).toString("utf8"),
headers: resp.headers,
});
});
},
);
req.on("timeout", () => {
req.destroy();
reject(new Error("request timed out"));
});
req.on("error", reject);
req.write(body);
req.end();
});
}
function encodeForm(params) {
return Object.entries(params)
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchTenantToken() {
const appId = requireEnv("LARKSUITE_CLI_APP_ID");
const appSecret = requireEnv("TEST_BOT1_APP_SECRET");
const body = encodeForm({
grant_type: "client_credentials",
client_id: appId,
client_secret: appSecret,
});
let lastError = "";
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
const { status, body: respBody, headers } = await postForm(ENDPOINT, body);
let payload;
try {
payload = JSON.parse(respBody);
} catch {
const logID = headers["x-tt-logid"] || headers["x-request-id"] || "unavailable";
lastError = `HTTP ${status}, log_id=${logID}, non-JSON response`;
}
if (payload) {
const token = payload.access_token;
if (status === 200 && payload.code === 0 && token) {
return token;
}
lastError = `HTTP ${status}, code=${payload.code}, error=${payload.error}, msg=${payload.msg || payload.error_description}`;
}
} catch (err) {
lastError = err.message;
}
if (attempt < MAX_ATTEMPTS) {
await sleep(2 ** (attempt - 1) * RETRY_BASE_MS);
}
}
console.error(`::error::Failed to fetch tenant access token: ${lastError}`);
process.exit(1);
}
async function main() {
const token = await fetchTenantToken();
console.log(`::add-mask::${token}`);
const tatPath = path.join(process.env.RUNNER_TEMP, "e2e-live-tat");
fs.writeFileSync(tatPath, token, { encoding: "utf8", mode: 0o600 });
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `path=${tatPath}\n`);
}
console.log("Prepared shared live E2E tenant token");
}
if (require.main === module) {
main();
}
module.exports = {
encodeForm,
fetchTenantToken,
postForm,
requireEnv,
};

View File

@@ -0,0 +1,203 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const { spawn } = require("node:child_process");
const test = require("node:test");
const scriptPath = path.join(__dirname, "fetch_e2e_tat.js");
function startServer(handler) {
const server = http.createServer((req, res) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
handler(req, res, body);
});
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const port = server.address().port;
resolve({ server, port });
});
});
}
function abortResponse(res) {
res.writeHead(200, {
"Content-Type": "application/json",
"Content-Length": "100",
});
res.write('{"code":0');
setImmediate(() => res.destroy());
}
function runScript(envOverrides) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "fetch-e2e-tat-"));
const githubOutput = path.join(tmpDir, "github-output");
const env = {
...process.env,
LARKSUITE_CLI_APP_ID: "test_app_id",
TEST_BOT1_APP_SECRET: "test-secret",
RUNNER_TEMP: tmpDir,
GITHUB_OUTPUT: githubOutput,
E2E_TAT_RETRY_BASE_MS: "10",
...envOverrides,
};
return new Promise((resolve) => {
const child = spawn(process.execPath, [scriptPath], {
cwd: path.join(__dirname, ".."),
env,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data) => {
stdout += data;
});
child.stderr.on("data", (data) => {
stderr += data;
});
child.on("close", (code) => {
const output = fs.existsSync(githubOutput)
? fs.readFileSync(githubOutput, "utf8")
: "";
resolve({ tmpDir, stdout, stderr, output, exitCode: code });
});
});
}
test("encodeForm encodes form parameters", () => {
const { encodeForm } = require(scriptPath);
const result = encodeForm({
grant_type: "client_credentials",
client_id: "abc&def",
client_secret: "test-secret",
note: "x=y",
});
const params = new URLSearchParams(result);
assert.equal(params.get("grant_type"), "client_credentials");
assert.equal(params.get("client_id"), "abc&def");
assert.equal(params.get("client_secret"), "test-secret");
assert.equal(params.get("note"), "x=y");
});
test("exits with error when app id is missing", async () => {
const result = await runScript({ LARKSUITE_CLI_APP_ID: "" });
assert.notEqual(result.exitCode, 0);
assert.match(result.stderr, /Missing required environment variable: LARKSUITE_CLI_APP_ID/);
});
test("exits with error when app secret is missing", async () => {
const result = await runScript({ TEST_BOT1_APP_SECRET: "" });
assert.notEqual(result.exitCode, 0);
assert.match(result.stderr, /Missing required environment variable: TEST_BOT1_APP_SECRET/);
});
test("fetches token and writes it to a private file", async () => {
const { server, port } = await startServer((req, res, body) => {
assert.equal(req.method, "POST");
const params = new URLSearchParams(body);
assert.equal(params.get("grant_type"), "client_credentials");
assert.equal(params.get("client_id"), "test_app_id");
assert.equal(params.get("client_secret"), "test-secret");
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: 0, access_token: "test-token" }));
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
assert.ok(result.stdout.includes("::add-mask::test-token"));
assert.ok(result.stdout.includes("Prepared shared live E2E tenant token"));
const tatPath = path.join(result.tmpDir, "e2e-live-tat");
assert.ok(fs.existsSync(tatPath), "token file should exist");
const stat = fs.statSync(tatPath);
assert.equal(stat.mode & 0o777, 0o600, "token file should be owner-only");
assert.equal(fs.readFileSync(tatPath, "utf8"), "test-token");
assert.ok(
result.output.includes(`path=${tatPath}`),
"should write path to GITHUB_OUTPUT",
);
} finally {
server.close();
}
});
test("retries an interrupted response and then succeeds", async () => {
let requestCount = 0;
const { server, port } = await startServer((req, res) => {
requestCount++;
if (requestCount === 1) {
abortResponse(res);
return;
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: 0, access_token: "test-token" }));
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.equal(result.exitCode, 0, `stderr: ${result.stderr}`);
assert.equal(requestCount, 2);
} finally {
server.close();
}
});
test("fails after every interrupted response is retried", async () => {
let requestCount = 0;
const { server, port } = await startServer((req, res) => {
requestCount++;
abortResponse(res);
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.notEqual(result.exitCode, 0);
assert.equal(requestCount, 4);
assert.match(result.stderr, /Failed to fetch tenant access token/);
} finally {
server.close();
}
});
test("exits with error after all retries fail", async () => {
let requestCount = 0;
const { server, port } = await startServer((req, res) => {
requestCount++;
res.writeHead(500, { "Content-Type": "application/json" });
res.end(JSON.stringify({ code: 500, error: "server error" }));
});
try {
const result = await runScript({
E2E_TAT_ENDPOINT: `http://127.0.0.1:${port}/token`,
});
assert.notEqual(result.exitCode, 0);
assert.equal(requestCount, 4);
assert.match(result.stderr, /Failed to fetch tenant access token/);
} finally {
server.close();
}
});

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

@@ -223,6 +223,24 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
args: []string{"--sheet-id", "sh1", "--chart-id", "c1"},
subInput: `{"sheet-id":"sh1","chart-id":"c1"}`,
},
{
shortcut: "+chart-create-basic",
sc: ChartCreateBasic,
args: []string{"--sheet-id", "sh1", "--chart-type", "column", "--data-range", "A1:C10", "--title", "Sales", "--data-labels", "value", "--anchor-cell", "F2"},
subInput: `{"sheet-id":"sh1","chart-type":"column","data-range":"A1:C10","title":"Sales","data-labels":"value","anchor-cell":"F2"}`,
},
{
shortcut: "+chart-config-update",
sc: ChartConfigUpdate,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--title", "Updated", "--data-labels", "category", "--data-label-position", "top"},
subInput: `{"sheet-id":"sh1","chart-id":"c1","title":"Updated","data-labels":"category","data-label-position":"top"}`,
},
{
shortcut: "+chart-data-update",
sc: ChartDataUpdate,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--data-range", "'Sheet1'!A1:M6", "--data-direction", "column", "--dim1-index", "1", "--dim2-indexes", "4,8"},
subInput: `{"sheet-id":"sh1","chart-id":"c1","data-range":"'Sheet1'!A1:M6","data-direction":"column","dim1-index":1,"dim2-indexes":"4,8"}`,
},
{
shortcut: "+pivot-create",
sc: PivotCreate,
@@ -424,6 +442,22 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
subInput: `{}`,
wantContains: "specify at least one of --sheet-id or --sheet-name",
},
{
name: "+chart-data-update invalid dim1 index",
shortcut: ChartDataUpdate,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--data-range", "A1:C4", "--dim1-index", "0"},
subShortcut: "+chart-data-update",
subInput: `{"sheet-id":"sh1","chart-id":"c1","data-range":"A1:C4","dim1-index":0}`,
wantContains: "--dim1-index must be a positive 1-based index",
},
{
name: "+chart-data-update dim1 and dim2 conflict",
shortcut: ChartDataUpdate,
args: []string{"--sheet-id", "sh1", "--chart-id", "c1", "--data-range", "A1:C4", "--dim1-index", "2", "--dim2-indexes", "2,3"},
subShortcut: "+chart-data-update",
subInput: `{"sheet-id":"sh1","chart-id":"c1","data-range":"A1:C4","dim1-index":2,"dim2-indexes":"2,3"}`,
wantContains: "--dim2-indexes must not contain the dim1 index 2",
},
{
name: "+float-image-create both image-token and image-uri",
shortcut: FloatImageCreate,
@@ -662,6 +696,18 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
`{"sheet-id":"sh1","properties":{"title":"T"}}`,
"--chart-id is required",
},
{
"+chart-data-update missing --chart-id",
"+chart-data-update",
`{"sheet-id":"sh1","data-range":"A1:C4"}`,
"--chart-id is required",
},
{
"+chart-data-update missing --data-range",
"+chart-data-update",
`{"sheet-id":"sh1","chart-id":"c1"}`,
"--data-range is required",
},
{
"+filter-create missing --range",
"+filter-create",

View File

@@ -168,9 +168,12 @@ var batchOpDispatch = map[string]batchOpMapping{
}},
// ─── 对象族 CRUD (manage_*_object, operation 区分) ─────────────
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
"+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)},
"+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)},
"+chart-create": {"manage_chart_object", objCreateTranslate(chartSpec)},
"+chart-update": {"manage_chart_object", objUpdateTranslate(chartSpec)},
"+chart-delete": {"manage_chart_object", objDeleteTranslate(chartSpec)},
"+chart-create-basic": {"manage_chart_object", chartCreateBasicInput},
"+chart-config-update": {"manage_chart_object", chartConfigUpdateInput},
"+chart-data-update": {"manage_chart_object", chartDataUpdateInput},
"+pivot-create": {"manage_pivot_table_object", objCreateTranslate(pivotSpec)},
"+pivot-update": {"manage_pivot_table_object", objUpdateTranslate(pivotSpec)},

View File

@@ -0,0 +1,150 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"fmt"
"sort"
"strings"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// ─── +chart-create --print-example ─────────────────────────────────────
//
// chart-create's --properties schema is ~1,750 pretty-printed lines; eval
// traces show agents paging through the full --print-schema dump for every
// chart (25 round trips in one 35-task batch) and still missing deep
// required fields. A ready-to-edit minimal template per chart type answers
// the actual question ("what does a valid payload look like") in one local
// call. Wired through PostMount, same pattern as +csv-put's flag-group
// tweaks — no framework change.
//
// Templates mirror the canonical examples in the lark-sheets-chart
// reference (sheet-skill-spec canonical-spec/references/lark_sheet_chart):
// inline headerMode with refs covering the header row, 1-based indices,
// quoted sheet prefix in refs.
var chartExampleTemplates = map[string]string{
"column": chartSimpleExample("column"),
"bar": chartSimpleExample("bar"),
"line": chartSimpleExample("line"),
"area": chartSimpleExample("area"),
"radar": chartSimpleExample("radar"),
"scatter": `{
"position": {"row": 1, "col": "F"},
"size": {"width": 600, "height": 400},
"snapshot": {
"title": {"text": "图表标题"},
"plotArea": {"plot": {"type": "scatter"}},
"data": {
"refs": [{"value": "'Sheet1'!A1:B20"}],
"dim1": {"serie": {"index": 1}},
"dim2": {"series": [{"index": 2}]}
}
}
}`,
"pie": `{
"position": {"row": 1, "col": "F"},
"size": {"width": 600, "height": 450},
"snapshot": {
"title": {"text": "占比标题"},
"plotArea": {"plot": {
"type": "pie",
"series": [{
"index": 1,
"sectors": {"sector": [{"index": 1, "offsetRadius": 0.05}]}
}]
}},
"data": {
"refs": [{"value": "'Sheet1'!A1:B11"}],
"dim1": {"serie": {"index": 1, "aggregate": true}},
"dim2": {"series": [{"index": 2, "aggregateType": "sum"}]}
}
}
}`,
"combo": `{
"position": {"row": 1, "col": "F"},
"size": {"width": 700, "height": 400},
"snapshot": {
"title": {"text": "柱线组合"},
"plotArea": {"plot": {
"type": "combo",
"series": [
{"index": 2, "comboType": "column"},
{"index": 3, "comboType": "line"}
]
}},
"data": {
"refs": [{"value": "'Sheet1'!A1:C13"}],
"dim1": {"serie": {"index": 1}},
"dim2": {"series": [{"index": 2}, {"index": 3}]}
}
}
}`,
}
// chartSimpleExample renders the shared minimal shape for plot types that
// need nothing beyond plot.type (column / bar / line / area / radar).
func chartSimpleExample(typ string) string {
return fmt.Sprintf(`{
"position": {"row": 1, "col": "F"},
"size": {"width": 600, "height": 400},
"snapshot": {
"title": {"text": "图表标题"},
"plotArea": {"plot": {"type": %q}},
"data": {
"refs": [{"value": "'Sheet1'!A1:C10"}],
"dim1": {"serie": {"index": 1}},
"dim2": {"series": [{"index": 2}, {"index": 3}]}
}
}
}`, typ)
}
func chartExampleTypes() []string {
types := make([]string, 0, len(chartExampleTemplates))
for t := range chartExampleTemplates {
types = append(types, t)
}
sort.Strings(types)
return types
}
// withChartPrintExample wraps +chart-create's PostMount so the command grows
// a --print-example flag that short-circuits execution and prints a minimal
// ready-to-edit --properties template — purely local, no identity or
// network. --properties' cobra-level required annotation is relaxed (the
// input builder still enforces it on the real path, same trick as
// +csv-put's --csv).
func withChartPrintExample(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
return func(cmd *cobra.Command) {
if prev != nil {
prev(cmd)
}
cmd.Flags().String("print-example", "",
"Print a minimal ready-to-edit --properties template for a chart type ("+strings.Join(chartExampleTypes(), "|")+") and exit")
// Only --properties carries a cobra-level required annotation (the
// locator flags are xor pairs, enforced later); the input builder
// still errors "--properties is required" on the real path.
if fl := cmd.Flags().Lookup("properties"); fl != nil {
delete(fl.Annotations, cobra.BashCompOneRequiredFlag)
}
prevRunE := cmd.RunE
cmd.RunE = func(c *cobra.Command, args []string) error {
typ, _ := c.Flags().GetString("print-example")
if typ == "" {
return prevRunE(c, args)
}
tmpl, ok := chartExampleTemplates[typ]
if !ok {
return common.ValidationErrorf("no example for chart type %q; available: %s",
typ, strings.Join(chartExampleTypes(), ", ")).WithParam("--print-example")
}
fmt.Fprintln(c.OutOrStdout(), tmpl)
return nil
}
}
}

View File

@@ -0,0 +1,63 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"strings"
"testing"
)
// TestChartPrintExample pins the --print-example contract: a known type
// prints its template and skips execution entirely; an unknown type lists
// the available ones.
func TestChartPrintExample(t *testing.T) {
t.Parallel()
t.Run("prints template without locator flags", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+chart-create")
parent, _, _, _ := newTestRig(t, sc)
var buf bytes.Buffer
parent.SetOut(&buf) // --print-example writes via cobra's OutOrStdout
parent.SetArgs([]string{sc.Command, "--print-example", "pie"})
if err := parent.Execute(); err != nil {
t.Fatalf("print-example should run standalone, got: %v", err)
}
if !strings.Contains(buf.String(), `"sectors"`) {
t.Errorf("pie template should carry sectors, got %q", buf.String())
}
})
t.Run("unknown type lists available", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+chart-create")
_, _, err := runShortcutCapturingErr(t, sc, []string{"--print-example", "donut"})
ve := requireValidation(t, err, `no example for chart type "donut"`)
if !strings.Contains(ve.Message, "pie") {
t.Errorf("message should list available types, got %q", ve.Message)
}
})
}
// TestChartExampleTemplates_ValidateAgainstSchema drift-guards every
// template against the embedded chart-create properties schema — a template
// the CLI itself would reject is worse than none.
func TestChartExampleTemplates_ValidateAgainstSchema(t *testing.T) {
t.Parallel()
for typ, tmpl := range chartExampleTemplates {
t.Run(typ, func(t *testing.T) {
t.Parallel()
var v interface{}
if err := json.Unmarshal([]byte(tmpl), &v); err != nil {
t.Fatalf("template is not valid JSON: %v", err)
}
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{"properties": v})
if err := validateValueAgainstSchema(fv, "properties", v); err != nil {
t.Errorf("template rejected by embedded schema: %v", err)
}
})
}
}

View File

@@ -1277,13 +1277,14 @@
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Comma-separated info categories to include",
"desc": "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)",
"enum": [
"value",
"formula",
"style",
"comment",
"data_validation"
"data_validation",
"truncation"
]
},
{
@@ -1291,9 +1292,16 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
"desc": "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.",
"default": "500000"
},
{
"name": "output-path",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
},
{
"name": "skip-hidden",
"kind": "own",
@@ -1400,9 +1408,16 @@
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more",
"desc": "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.",
"default": "500000"
},
{
"name": "output-path",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
},
{
"name": "include-row-prefix",
"kind": "own",
@@ -1465,6 +1480,21 @@
"required": "optional",
"desc": "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"
},
{
"name": "max-chars",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (auto-unlimited).",
"default": "500000"
},
{
"name": "output-path",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."
},
{
"name": "no-header",
"kind": "own",
@@ -3219,6 +3249,559 @@
}
]
},
"+chart-create-basic": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "chart-type",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Chart type",
"enum": [
"column",
"bar",
"line",
"area",
"pie",
"scatter",
"combo",
"radar"
]
},
{
"name": "data-range",
"kind": "own",
"type": "string",
"required": "required",
"desc": "One contiguous A1 range including headers, or comma-separated same-sheet ranges; aligned non-overlapping ranges stay independent, otherwise they merge to the smallest enclosing rectangle"
},
{
"name": "data-direction",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data series direction; column uses the first column as categories, row uses the first row",
"default": "column",
"enum": [
"column",
"row"
]
},
{
"name": "title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Chart title"
},
{
"name": "subtitle",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Chart subtitle"
},
{
"name": "legend-position",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Legend position; hidden removes the legend",
"enum": [
"top",
"bottom",
"left",
"right",
"hidden"
]
},
{
"name": "x-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "X-axis title"
},
{
"name": "y-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Left Y-axis title"
},
{
"name": "secondary-y-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Right Y-axis title"
},
{
"name": "x-axis-label-angle",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "X-axis label angle",
"enum": [
"-90",
"-45",
"0",
"45",
"90"
]
},
{
"name": "y-axis-label-angle",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Left Y-axis label angle",
"enum": [
"-90",
"-45",
"0",
"45",
"90"
]
},
{
"name": "data-labels",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data label content; none removes labels; category_percentage is normalized to value_percentage",
"enum": [
"none",
"value",
"percentage",
"value_percentage",
"category_percentage",
"category",
"series"
]
},
{
"name": "data-label-position",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data label position",
"enum": [
"auto",
"top",
"bottom",
"left",
"right",
"center",
"inside",
"outside"
]
},
{
"name": "stack",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Stacking mode",
"enum": [
"none",
"normal",
"percent"
]
},
{
"name": "stacked",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Compatibility alias for --stack normal",
"hidden": true
},
{
"name": "smooth",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Use smooth curves; accepts both --smooth=false and --smooth false"
},
{
"name": "color-palette",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Preset chart-level color palette; mutually exclusive with --colors",
"enum": [
"brandColorSeries@v2",
"rainbowColorSeries@v2",
"complementaryColorSeries@v2",
"converseColorSeries@v2",
"primaryColorSeries@v2",
"singleColorSeries-B-@v2",
"singleColorSeries-W-@v2",
"singleColorSeries-G-@v2",
"singleColorSeries-Y-@v2",
"singleColorSeries-O-@v2",
"singleColorSeries-R-@v2",
"singleColorSeries-D-@v2"
]
},
{
"name": "colors",
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"
},
{
"name": "anchor-cell",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Optional chart anchor cell such as F2; defaults to the right of the data range"
},
{
"name": "width",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Optional chart width; must be paired with --height"
},
{
"name": "height",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Optional chart height; must be paired with --width"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request template; no side effects"
}
]
},
"+chart-config-update": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "chart-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target chart reference_id"
},
{
"name": "title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Chart title"
},
{
"name": "subtitle",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Chart subtitle"
},
{
"name": "legend-position",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Legend position; hidden removes the legend",
"enum": [
"top",
"bottom",
"left",
"right",
"hidden"
]
},
{
"name": "x-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "X-axis title"
},
{
"name": "y-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Left Y-axis title"
},
{
"name": "secondary-y-axis-title",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Right Y-axis title"
},
{
"name": "x-axis-label-angle",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "X-axis label angle",
"enum": [
"-90",
"-45",
"0",
"45",
"90"
]
},
{
"name": "y-axis-label-angle",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Left Y-axis label angle",
"enum": [
"-90",
"-45",
"0",
"45",
"90"
]
},
{
"name": "data-labels",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data label content; none removes labels; category_percentage is normalized to value_percentage",
"enum": [
"none",
"value",
"percentage",
"value_percentage",
"category_percentage",
"category",
"series"
]
},
{
"name": "data-label-position",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data label position",
"enum": [
"auto",
"top",
"bottom",
"left",
"right",
"center",
"inside",
"outside"
]
},
{
"name": "stack",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Stacking mode",
"enum": [
"none",
"normal",
"percent"
]
},
{
"name": "stacked",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Compatibility alias for --stack normal",
"hidden": true
},
{
"name": "smooth",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "Use smooth curves; accepts both --smooth=false and --smooth false"
},
{
"name": "color-palette",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Preset chart-level color palette; mutually exclusive with --colors",
"enum": [
"brandColorSeries@v2",
"rainbowColorSeries@v2",
"complementaryColorSeries@v2",
"converseColorSeries@v2",
"primaryColorSeries@v2",
"singleColorSeries-B-@v2",
"singleColorSeries-W-@v2",
"singleColorSeries-G-@v2",
"singleColorSeries-Y-@v2",
"singleColorSeries-O-@v2",
"singleColorSeries-R-@v2",
"singleColorSeries-D-@v2"
]
},
{
"name": "colors",
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request template; no side effects"
}
]
},
"+chart-data-update": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet reference_id (XOR with `--sheet-name`)"
},
{
"name": "sheet-name",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)"
},
{
"name": "chart-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Target chart reference_id"
},
{
"name": "data-range",
"kind": "own",
"type": "string",
"required": "required",
"desc": "New data range including headers; accepts comma-separated same-sheet ranges and normalizes misaligned or overlapping ranges"
},
{
"name": "data-direction",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Data series direction; defaults to the existing chart direction when omitted",
"enum": [
"column",
"row"
]
},
{
"name": "dim1-index",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "1-based category/X-axis dimension index within the data range; defaults to the first dimension"
},
{
"name": "dim2-indexes",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Comma-separated 1-based value/Y-axis series indexes within the data range; defaults to all dimensions except dim1"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": "Print the request template; no side effects"
}
]
},
"+chart-create": {
"risk": "write",
"flags": [
@@ -3313,7 +3896,7 @@
"kind": "own",
"type": "string",
"required": "required",
"desc": "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)",
"desc": "Chart config patch JSON; send changed fields only by default; omitted fields are preserved, objects merge recursively, and arrays replace as a whole",
"input": [
"file",
"stdin"

File diff suppressed because it is too large Load Diff

View File

@@ -75,8 +75,9 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F10` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include", Enum: []string{"value", "formula", "style", "comment", "data_validation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "include", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Comma-separated info categories to include. `truncation` additionally estimates whether each cell's content is clipped (by row height / col width / font size / wrap) and returns `isRowTruncated` / `isColTruncated` (extra compute; enable only for layout checks or before adjusting row heights / column widths)", Enum: []string{"value", "formula", "style", "comment", "data_validation", "truncation"}},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
@@ -199,6 +200,32 @@ var flagDefs = map[string]commandDef{
{Name: "end-revision", Kind: "own", Type: "int", Required: "optional", Desc: "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", Default: "-1"},
},
},
"+chart-config-update": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Chart title"},
{Name: "subtitle", Kind: "own", Type: "string", Required: "optional", Desc: "Chart subtitle"},
{Name: "legend-position", Kind: "own", Type: "string", Required: "optional", Desc: "Legend position; hidden removes the legend", Enum: []string{"top", "bottom", "left", "right", "hidden"}},
{Name: "x-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "X-axis title"},
{Name: "y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Left Y-axis title"},
{Name: "secondary-y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Right Y-axis title"},
{Name: "x-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "X-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
{Name: "y-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "Left Y-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
{Name: "data-labels", Kind: "own", Type: "string", Required: "optional", Desc: "Data label content; none removes labels; category_percentage is normalized to value_percentage", Enum: []string{"none", "value", "percentage", "value_percentage", "category_percentage", "category", "series"}},
{Name: "data-label-position", Kind: "own", Type: "string", Required: "optional", Desc: "Data label position", Enum: []string{"auto", "top", "bottom", "left", "right", "center", "inside", "outside"}},
{Name: "stack", Kind: "own", Type: "string", Required: "optional", Desc: "Stacking mode", Enum: []string{"none", "normal", "percent"}},
{Name: "stacked", Kind: "own", Type: "bool", Required: "optional", Desc: "Compatibility alias for --stack normal", Hidden: true},
{Name: "smooth", Kind: "own", Type: "bool", Required: "optional", Desc: "Use smooth curves; accepts both --smooth=false and --smooth false"},
{Name: "color-palette", Kind: "own", Type: "string", Required: "optional", Desc: "Preset chart-level color palette; mutually exclusive with --colors", Enum: []string{"brandColorSeries@v2", "rainbowColorSeries@v2", "complementaryColorSeries@v2", "converseColorSeries@v2", "primaryColorSeries@v2", "singleColorSeries-B-@v2", "singleColorSeries-W-@v2", "singleColorSeries-G-@v2", "singleColorSeries-Y-@v2", "singleColorSeries-O-@v2", "singleColorSeries-R-@v2", "singleColorSeries-D-@v2"}},
{Name: "colors", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
"+chart-create": {
Risk: "write",
Flags: []flagDef{
@@ -210,6 +237,52 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
"+chart-create-basic": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-type", Kind: "own", Type: "string", Required: "required", Desc: "Chart type", Enum: []string{"column", "bar", "line", "area", "pie", "scatter", "combo", "radar"}},
{Name: "data-range", Kind: "own", Type: "string", Required: "required", Desc: "One contiguous A1 range including headers, or comma-separated same-sheet ranges; aligned non-overlapping ranges stay independent, otherwise they merge to the smallest enclosing rectangle"},
{Name: "data-direction", Kind: "own", Type: "string", Required: "optional", Desc: "Data series direction; column uses the first column as categories, row uses the first row", Default: "column", Enum: []string{"column", "row"}},
{Name: "title", Kind: "own", Type: "string", Required: "optional", Desc: "Chart title"},
{Name: "subtitle", Kind: "own", Type: "string", Required: "optional", Desc: "Chart subtitle"},
{Name: "legend-position", Kind: "own", Type: "string", Required: "optional", Desc: "Legend position; hidden removes the legend", Enum: []string{"top", "bottom", "left", "right", "hidden"}},
{Name: "x-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "X-axis title"},
{Name: "y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Left Y-axis title"},
{Name: "secondary-y-axis-title", Kind: "own", Type: "string", Required: "optional", Desc: "Right Y-axis title"},
{Name: "x-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "X-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
{Name: "y-axis-label-angle", Kind: "own", Type: "int", Required: "optional", Desc: "Left Y-axis label angle", Enum: []string{"-90", "-45", "0", "45", "90"}},
{Name: "data-labels", Kind: "own", Type: "string", Required: "optional", Desc: "Data label content; none removes labels; category_percentage is normalized to value_percentage", Enum: []string{"none", "value", "percentage", "value_percentage", "category_percentage", "category", "series"}},
{Name: "data-label-position", Kind: "own", Type: "string", Required: "optional", Desc: "Data label position", Enum: []string{"auto", "top", "bottom", "left", "right", "center", "inside", "outside"}},
{Name: "stack", Kind: "own", Type: "string", Required: "optional", Desc: "Stacking mode", Enum: []string{"none", "normal", "percent"}},
{Name: "stacked", Kind: "own", Type: "bool", Required: "optional", Desc: "Compatibility alias for --stack normal", Hidden: true},
{Name: "smooth", Kind: "own", Type: "bool", Required: "optional", Desc: "Use smooth curves; accepts both --smooth=false and --smooth false"},
{Name: "color-palette", Kind: "own", Type: "string", Required: "optional", Desc: "Preset chart-level color palette; mutually exclusive with --colors", Enum: []string{"brandColorSeries@v2", "rainbowColorSeries@v2", "complementaryColorSeries@v2", "converseColorSeries@v2", "primaryColorSeries@v2", "singleColorSeries-B-@v2", "singleColorSeries-W-@v2", "singleColorSeries-G-@v2", "singleColorSeries-Y-@v2", "singleColorSeries-O-@v2", "singleColorSeries-R-@v2", "singleColorSeries-D-@v2"}},
{Name: "colors", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Custom chart-level series colors as a comma-separated list of at least two hex colors; mutually exclusive with --color-palette"},
{Name: "anchor-cell", Kind: "own", Type: "string", Required: "optional", Desc: "Optional chart anchor cell such as F2; defaults to the right of the data range"},
{Name: "width", Kind: "own", Type: "int", Required: "optional", Desc: "Optional chart width; must be paired with --height"},
{Name: "height", Kind: "own", Type: "int", Required: "optional", Desc: "Optional chart height; must be paired with --width"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
"+chart-data-update": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "data-range", Kind: "own", Type: "string", Required: "required", Desc: "New data range including headers; accepts comma-separated same-sheet ranges and normalizes misaligned or overlapping ranges"},
{Name: "data-direction", Kind: "own", Type: "string", Required: "optional", Desc: "Data series direction; defaults to the existing chart direction when omitted", Enum: []string{"column", "row"}},
{Name: "dim1-index", Kind: "own", Type: "int", Required: "optional", Desc: "1-based category/X-axis dimension index within the data range; defaults to the first dimension"},
{Name: "dim2-indexes", Kind: "own", Type: "string", Required: "optional", Desc: "Comma-separated 1-based value/Y-axis series indexes within the data range; defaults to all dimensions except dim1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
},
},
"+chart-delete": {
Risk: "high-risk-write",
Flags: []flagDef{
@@ -241,7 +314,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "chart-id", Kind: "own", Type: "string", Required: "required", Desc: "Target chart reference_id"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full or sufficiently complete chart config JSON (read back with `+chart-list` first, then patch)", Input: []string{"file", "stdin"}},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Chart config patch JSON; send changed fields only by default; omitted fields are preserved, objects merge recursively, and arrays replace as a whole", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
@@ -317,7 +390,8 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "A1 range, e.g. `A1:F30` (no sheet prefix — use `--sheet-id` / `--sheet-name` to select the sheet)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). Large reads are usually better redirected to a file; only lower it (e.g. 25000) when you want results inline without triggering file offload, paging via has_more", Default: "500000"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). For a full untruncated read, use --output-path to dump to a file (auto-unlimited); only lower it (e.g. 25000) when you want results inline without a file, paging via has_more.", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
{Name: "include-row-prefix", Kind: "own", Type: "bool", Required: "optional", Desc: "Whether to prefix each row with `[row=N]`; default `true`", Default: "true"},
{Name: "skip-hidden", Kind: "own", Type: "bool", Required: "optional", Desc: "Skip hidden rows and columns; default `false`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request path and parameters without executing"},
@@ -983,6 +1057,8 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by id); omit to read all sheets"},
{Name: "sheet-name", Kind: "own", Type: "string", Required: "optional", Desc: "Read only this sheet (by name); omit to read all sheets"},
{Name: "range", Kind: "own", Type: "string", Required: "optional", Desc: "A1 range to read; omit to read each sheet's full used range (spans internal blank rows/columns, not just the A1 current region)"},
{Name: "max-chars", Kind: "own", Type: "int", Required: "optional", Desc: "Max output chars per call; default 500000 (safety cap). The underlying tool truncates at ~50000 even when unset, so this is sent explicitly to raise it; for a full untruncated read use --output-path (auto-unlimited).", Default: "500000"},
{Name: "output-path", Kind: "own", Type: "string", Required: "optional", Desc: "Write the full read result to a local path (e.g. `./out.json`); the file holds the data payload as JSON while stdout returns only a small confirmation (output_path, byte count). **When set, the char cap defaults to unlimited** (overriding the --max-chars default), so a large sheet can be dumped in full for later analysis without stdout being truncated by max_chars. Omit it to print to stdout as usual."},
{Name: "no-header", Kind: "own", Type: "bool", Required: "optional", Desc: "Treat the first row as data instead of a header (columns get positional names col1, col2, ...)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},

View File

@@ -38,9 +38,95 @@ func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command)
}
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
chainEnumNormalization(cmd)
chainFlagAliases(cmd)
}
}
// ─── intuitive flag names: silent aliases & prescriptions ───────────────
//
// Eval traces show unknown-flag failures cluster on a handful of habitual
// names (--file, --cols, --dimension, --start-cell, --bold, --source…) that
// agents import from generic CLI / Excel vocabulary. Two tiers, mirroring
// the enum-normalization contract above: a name whose value semantics are
// identical to the real flag is rewritten silently (zero round-trips); a
// name whose fix changes the value or moves it into a JSON field gets a
// curated prescription on the unknown-flag error instead — never a silent
// rewrite.
// commandFlagAliases maps, per command, habitual flag names onto the flag
// actually registered. Only pairs with identical value semantics belong
// here: the rewrite is invisible, so it must be safe to apply unread
// (+csv-put --file with a path value still trips the file-path guard, which
// prescribes @file / stdin).
var commandFlagAliases = map[string]map[string]string{
"+csv-put": {"file": "csv"},
"+sheet-create": {"name": "title"},
"+cols-resize": {"cols": "range"},
"+rows-resize": {"rows": "range"},
"+range-fill": {"source": "source-range", "target": "target-range"},
"+range-copy": {"source": "source-range", "target": "target-range"},
"+range-move": {"source": "source-range", "target": "target-range"},
}
// intuitiveFlagHints carries the prescription for habitual names whose fix
// is not a 1:1 rename — the value belongs to a different flag or to a field
// inside a JSON payload. The hint spells the exact correct form so the
// retry needs no --help round trip.
var intuitiveFlagHints = map[string]map[string]string{
"+sheet-copy": {
"new-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
"target-sheet-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
"new-name": "the copy's name goes in --title; --sheet-name / --sheet-id selects the source sheet",
},
"+dim-insert": {
"dimension": "+dim-insert infers rows vs columns from --position: a row number like 3 inserts rows, a column letter like C inserts columns; pair with --count N",
},
"+dim-freeze": {
"frozen-rows": "freeze the first N rows with --dimension row --count N",
"frozen-cols": "freeze the first N columns with --dimension column --count N",
"frozen-columns": "freeze the first N columns with --dimension column --count N",
},
"+cells-set-style": {
"bold": "use --font-weight bold",
"italic": "use --font-style italic",
"underline": "use --font-line underline",
},
"+table-put": {
"start-cell": `anchor each sub-sheet via the "start_cell" field inside --sheets (e.g. {"sheets":[{"name":"Sheet1","start_cell":"B2",…}]}); to paste CSV at a cell use +csv-put --start-cell`,
"sheet-name": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
"sheet-id": `+table-put has no sheet selector — each --sheets item carries its own "name" field ({"sheets":[{"name":"Sheet1",…}]})`,
},
}
// chainFlagAliases composes two rewrites onto the flag-name normalize hook
// (on top of any hook a prior PostMount installed, e.g. --token →
// --spreadsheet-token): the wire-vocabulary underscore form of any flag
// (--sheet_name, --border_styles — no sheets flag has an underscore in its
// canonical name), and the command's intuitive-alias table. Either way a
// habitual name parses as the real flag with zero round trips. Aliases
// never shadow a registered flag and never appear in --help; an alias whose
// target vanished (spec-side rename) is dropped, degrading to the
// unknown-flag prescription.
func chainFlagAliases(cmd *cobra.Command) {
aliases := commandFlagAliases[cmd.Name()]
usable := make(map[string]string, len(aliases))
for alias, target := range aliases {
if cmd.Flags().Lookup(alias) == nil && cmd.Flags().Lookup(target) != nil {
usable[alias] = target
}
}
prev := cmd.Flags().GetNormalizeFunc()
cmd.Flags().SetNormalizeFunc(func(fs *pflag.FlagSet, name string) pflag.NormalizedName {
if strings.Contains(name, "_") {
name = strings.ReplaceAll(name, "_", "-")
}
if target, ok := usable[name]; ok {
name = target
}
return prev(fs, name)
})
}
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
// It keeps the root behavior (typed error, did-you-mean suggestions, the
// offending flag on params) and additionally inlines the full valid-flag
@@ -67,6 +153,14 @@ func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
strings.Join(suggestions, ", "), list)
}
}
// A curated prescription beats both: it spells the exact correct form
// for a habitual name whose fix is not a rename (see intuitiveFlagHints).
if rx, ok := intuitiveFlagHints[c.Name()][name]; ok {
hint = rx
if list := inlineFlagList(valid); list != "" {
hint = rx + "; valid flags: " + list
}
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
@@ -139,6 +233,16 @@ var enumAliases = map[string]string{
"center": "middle", // CSS vertical-align: center → Lark "middle"
"centre": "center",
"middle": "center", // CSS-style middle → Lark horizontal "center"
// Raw Lark OpenAPI merge vocabulary (MERGE_ALL/…) — agents reproduce it
// from the API docs; lowercased by canonicalEnumValue before lookup.
"merge_all": "all",
"merge_rows": "rows",
"merge_columns": "columns",
// Boolean-style word-wrap habits: true unambiguously means wrap on;
// false means "don't wrap", whose Lark default is overflow (word-clip is
// a distinct truncation mode nobody spells "false").
"true": "auto-wrap",
"false": "overflow",
}
// canonicalEnumValue returns the enum entry an off-vocabulary value

View File

@@ -284,9 +284,9 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--cols", "A:D",
"--col-size", "A:D",
})
ve := requireValidation(t, err, `unknown flag "--cols"`)
ve := requireValidation(t, err, `unknown flag "--col-size"`)
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
@@ -294,3 +294,158 @@ func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
}
})
}
// TestShortcuts_IntuitiveFlagAliases verifies the silent-alias tier: a
// habitual name with identical value semantics parses as the real flag on a
// mounted command, costing zero round trips (eval: --cols, --file, --name,
// --source/--target each burned an unknown-flag failure plus a --help call).
func TestShortcuts_IntuitiveFlagAliases(t *testing.T) {
t.Parallel()
t.Run("cols-resize --cols parses as --range", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cols-resize")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--cols", "A:D",
"--width", "100",
"--dry-run",
})
if err != nil {
t.Fatalf("--cols should alias to --range and pass, got: %v", err)
}
if !strings.Contains(stdout, "A:D") {
t.Errorf("dry-run body should carry the aliased range, got %q", stdout)
}
})
t.Run("sheet-create --name parses as --title", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+sheet-create")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--name", "汇总",
"--dry-run",
})
if err != nil {
t.Fatalf("--name should alias to --title and pass, got: %v", err)
}
if !strings.Contains(stdout, "汇总") {
t.Errorf("dry-run body should carry the aliased title, got %q", stdout)
}
})
t.Run("range-fill --source/--target parse as ranges", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+range-fill")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--source", "B2",
"--target", "B3:B10",
"--dry-run",
})
if err != nil {
t.Fatalf("--source/--target should alias to the -range flags, got: %v", err)
}
for _, want := range []string{"B2", "B3:B10"} {
if !strings.Contains(stdout, want) {
t.Errorf("dry-run body should carry %q, got %q", want, stdout)
}
}
})
t.Run("csv-put --file parses as --csv", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+csv-put")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--start-cell", "A1",
"--file", "a,b\n1,2",
"--dry-run",
})
if err != nil {
t.Fatalf("--file with CSV text should alias to --csv and pass, got: %v", err)
}
if !strings.Contains(stdout, "a,b") {
t.Errorf("dry-run body should carry the CSV text, got %q", stdout)
}
})
t.Run("alias never shadows a registered flag", func(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "+csv-put"}
c.Flags().String("csv", "", "")
c.Flags().String("file", "", "") // hypothetical real flag wins
chainFlagAliases(c)
if err := c.ParseFlags([]string{"--file", "x"}); err != nil {
t.Fatalf("parse: %v", err)
}
if got, _ := c.Flags().GetString("file"); got != "x" {
t.Errorf("registered --file should keep its own value, got %q", got)
}
if got, _ := c.Flags().GetString("csv"); got != "" {
t.Errorf("--csv must stay empty when --file is a real flag, got %q", got)
}
})
}
// TestShortcuts_IntuitiveFlagHints verifies the prescription tier: habitual
// names whose fix is not a rename answer with the exact correct form, so the
// retry needs no --help round trip (eval: +sheet-copy burned 3/3 post-error
// --help calls, +dim-insert kept failing even after reading help).
func TestShortcuts_IntuitiveFlagHints(t *testing.T) {
t.Parallel()
cases := []struct {
command string
args []string
wrong string
wantHint []string
}{
{
command: "+dim-insert",
args: []string{"--url", testURL, "--sheet-name", "s", "--dimension", "row"},
wrong: "--dimension",
wantHint: []string{"--position", "--count"},
},
{
command: "+dim-freeze",
args: []string{"--url", testURL, "--sheet-name", "s", "--frozen-rows", "2"},
wrong: "--frozen-rows",
wantHint: []string{"--dimension row --count N"},
},
{
command: "+cells-set-style",
args: []string{"--url", testURL, "--sheet-name", "s", "--range", "A1", "--bold", "true"},
wrong: "--bold",
wantHint: []string{"--font-weight bold"},
},
{
command: "+sheet-copy",
args: []string{"--url", testURL, "--sheet-name", "s", "--new-sheet-name", "副本"},
wrong: "--new-sheet-name",
wantHint: []string{"--title", "source sheet"},
},
{
command: "+table-put",
args: []string{"--url", testURL, "--sheets", "{}", "--start-cell", "B2"},
wrong: "--start-cell",
wantHint: []string{`"start_cell"`, "+csv-put"},
},
}
for _, tc := range cases {
t.Run(tc.command+" "+tc.wrong, func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, tc.command)
_, _, err := runShortcutCapturingErr(t, sc, tc.args)
ve := requireValidation(t, err, "unknown flag \""+tc.wrong+"\"")
for _, want := range tc.wantHint {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
}
}

View File

@@ -7,6 +7,7 @@ import (
_ "embed"
"encoding/json"
"sort"
"strings"
"sync"
"github.com/larksuite/cli/errs"
@@ -84,6 +85,13 @@ func commandsWithFlagSchema() map[string]struct{} {
// listing of introspectable flags; otherwise it returns the schema
// subtree JSON for the named flag, or an error if the flag is not
// registered.
//
// flagName also accepts a dotted path (properties.plotArea.axes): the
// first segment names the flag, the rest walk the schema's properties
// (descending through array items implicitly), returning just that
// subtree. Large schemas — chart-create's properties is ~1,750 pretty
// lines — otherwise force agents to page through the full dump for one
// nested field; eval traces show 25 such round trips in one batch.
func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
return func(flagName string) ([]byte, error) {
idx, err := loadFlagSchemas()
@@ -103,10 +111,19 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
return json.MarshalIndent(map[string]interface{}{
"shortcut": command,
"introspectable_flags": flags,
"hint": "run again with --flag-name <name> to dump the JSON Schema for that flag",
"hint": "run again with --flag-name <name> to dump that flag's JSON Schema, or a dotted path like <name>.plotArea.axes to dump just one subtree",
}, "", " ")
}
schema, ok := entry[flagName]
name, path := splitSchemaPath(flagName)
schema, ok := entry[name]
if !ok {
// Tolerate the wire-vocabulary underscore form (--flag-name
// border_styles for border-styles) — agents copy field names out
// of JSON payloads where underscores are canonical.
if alt := strings.ReplaceAll(name, "_", "-"); alt != name {
schema, ok = entry[alt]
}
}
if !ok {
flags := make([]string, 0, len(entry))
for f := range entry {
@@ -114,14 +131,133 @@ func printFlagSchemaFor(command string) func(flagName string) ([]byte, error) {
}
sort.Strings(flags)
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"no JSON Schema registered for %s --%s; available: %v", command, flagName, flags).
"no JSON Schema registered for %s --%s; available: %v", command, name, flags).
WithParam("--flag-name")
}
// Reformat for readability — schema files store compact JSON.
var pretty interface{}
if err := json.Unmarshal(schema, &pretty); err != nil {
return nil, err
}
if len(path) > 0 {
pretty, err = sliceSchemaByPath(pretty, name, path)
if err != nil {
return nil, err
}
}
// Reformat for readability — schema files store compact JSON.
return json.MarshalIndent(pretty, "", " ")
}
}
// splitSchemaPath splits a --flag-name value into the flag name and the
// optional dotted schema path after it.
func splitSchemaPath(flagName string) (string, []string) {
parts := strings.Split(flagName, ".")
return parts[0], parts[1:]
}
// sliceSchemaByPath walks a decoded JSON Schema along dotted path segments.
// Each segment matches a key under "properties"; array levels are descended
// implicitly through "items" (an explicit "items" segment also works), and
// oneOf / anyOf branches are searched for the first one carrying the key. A miss
// errors with the keys actually available at that level so the caller can
// re-issue the path without a full dump.
func sliceSchemaByPath(schema interface{}, flagName string, path []string) (interface{}, error) {
node := schema
walked := flagName
for _, seg := range path {
next, ok := schemaChild(node, seg)
if !ok {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"no %q under %s; available keys: %v", seg, walked, schemaChildKeys(node)).
WithParam("--flag-name")
}
node = next
walked += "." + seg
}
return node, nil
}
// schemaChild resolves one path segment against a schema node, descending
// through items / oneOf / anyOf wrappers as needed.
func schemaChild(node interface{}, seg string) (interface{}, bool) {
for depth := 0; depth < 8; depth++ {
m, ok := node.(map[string]interface{})
if !ok {
return nil, false
}
if seg == "items" {
if items, ok := m["items"]; ok {
return items, true
}
}
if props, ok := m["properties"].(map[string]interface{}); ok {
if child, ok := props[seg]; ok {
return child, true
}
}
if items, ok := m["items"]; ok {
node = items
continue
}
if branches, ok := m["oneOf"].([]interface{}); ok {
for _, b := range branches {
if child, ok := schemaChild(b, seg); ok {
return child, true
}
}
}
if branches, ok := m["anyOf"].([]interface{}); ok {
for _, b := range branches {
if child, ok := schemaChild(b, seg); ok {
return child, true
}
}
}
return nil, false
}
return nil, false
}
// schemaChildKeys lists the property keys reachable at a schema node (through
// items / oneOf / anyOf wrappers), for the path-miss error.
func schemaChildKeys(node interface{}) []string {
seen := map[string]struct{}{}
var collect func(n interface{}, depth int)
collect = func(n interface{}, depth int) {
if depth > 8 {
return
}
m, ok := n.(map[string]interface{})
if !ok {
return
}
if props, ok := m["properties"].(map[string]interface{}); ok {
for k := range props {
seen[k] = struct{}{}
}
return
}
if items, ok := m["items"]; ok {
collect(items, depth+1)
return
}
if branches, ok := m["oneOf"].([]interface{}); ok {
for _, b := range branches {
collect(b, depth+1)
}
}
if branches, ok := m["anyOf"].([]interface{}); ok {
for _, b := range branches {
collect(b, depth+1)
}
}
}
collect(node, 0)
keys := make([]string, 0, len(seen))
for k := range seen {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}

View File

@@ -407,6 +407,13 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
}
return nil, sheetsValidationForFlag(name, "--%s: invalid JSON: %v", name, err).WithCause(err)
}
// Unambiguous habitual shapes are rewritten onto the wire contract
// before validation (see jsonFlagNormalizers). Runs on the parsed value,
// so both the standalone cobra path and +batch-update sub-ops (whose
// mapFlagView.Str re-encodes composites through here) get the rewrite.
if norm := jsonFlagNormalizers[runtime.Command()][name]; norm != nil {
out = norm(out)
}
// Schema-driven flag validation at the user-input boundary. Skips
// --properties (validated at the input-builder tail after enhance
// hooks fill in flat-flag-derived fields) and any flag without an
@@ -417,6 +424,92 @@ func parseJSONFlag(runtime flagView, name string) (interface{}, error) {
return out, nil
}
// jsonFlagNormalizers rewrites, per (command, flag), unambiguous habitual
// input shapes onto the wire contract before schema validation — same
// contract as enum normalization: only a shape whose meaning is beyond
// doubt may be rewritten; anything ambiguous must fail with a prescription
// instead. Applied to the parsed JSON value inside parseJSONFlag.
var jsonFlagNormalizers = map[string]map[string]func(interface{}) interface{}{
"+cells-set": {"cells": wrapLoneCellObject},
"+chart-create": {"properties": normalizeChartHexColors},
"+chart-update": {"properties": normalizeChartHexColors},
}
// normalizeChartHexColors walks a chart properties payload and prefixes bare
// 6/8-digit hex values on color keys with '#' (4472C4 → #4472C4 — the
// Excel-habit form the chart backend rejects with "expected rgba() or
// #RRGGBB/#RRGGBBAA"). In-place, recursive; anything not unambiguously a
// bare hex color is untouched.
func normalizeChartHexColors(v interface{}) interface{} {
switch t := v.(type) {
case map[string]interface{}:
for k, val := range t {
if s, ok := val.(string); ok && isColorKey(k) && isBareHexColor(s) {
t[k] = "#" + s
continue
}
normalizeChartHexColors(val)
}
case []interface{}:
for _, e := range t {
normalizeChartHexColors(e)
}
}
return v
}
func isColorKey(k string) bool {
return k == "color" || strings.HasSuffix(k, "_color") || strings.HasSuffix(k, "Color")
}
func isBareHexColor(s string) bool {
if len(s) != 6 && len(s) != 8 {
return false
}
for _, r := range s {
switch {
case r >= '0' && r <= '9', r >= 'a' && r <= 'f', r >= 'A' && r <= 'F':
default:
return false
}
}
return true
}
// cellObjectKeys pins the property vocabulary of a single cell in the
// +cells-set --cells schema ([[{…}]]). Drift against the embedded schema is
// guarded by TestCellObjectKeys_MatchEmbeddedSchema.
var cellObjectKeys = map[string]struct{}{
"border_styles": {},
"cell_styles": {},
"data_validation": {},
"formula": {},
"multiple_values": {},
"note": {},
"rich_text": {},
"value": {},
}
// wrapLoneCellObject rewrites a bare cell object into the [[cell]] the
// --cells contract expects. Eval traces show agents writing a single cell
// routinely pass {"value":…} without the two array layers; when every key
// belongs to the cell vocabulary the meaning is a 1×1 write and the wrap is
// safe. Anything else (unknown keys, arrays — one bracket layer could be a
// row or a column) is returned untouched for the schema validator to
// prescribe.
func wrapLoneCellObject(v interface{}) interface{} {
obj, ok := v.(map[string]interface{})
if !ok || len(obj) == 0 {
return v
}
for k := range obj {
if _, known := cellObjectKeys[k]; !known {
return v
}
}
return []interface{}{[]interface{}{obj}}
}
// requireJSONObject is parseJSONFlag + a type assertion to map[string]interface{}.
func requireJSONObject(runtime flagView, name string) (map[string]interface{}, error) {
v, err := parseJSONFlag(runtime, name)
@@ -533,8 +626,11 @@ func normalizeCellStyleAliases(style map[string]interface{}, path string) error
// normalizeTypedCellsStyleAliases walks a typed --cells 2D array and applies
// normalizeCellStyleAliases to every cell's inline cell_styles object, so the
// alignment shorthands are accepted on +cells-set the same as on --styles.
// Structure is checked leniently to match the pass-through contract: any
// element that isn't the expected shape is skipped, not rejected.
// It also expands the border "all" shorthand and intercepts border_styles
// mis-nested inside cell_styles — both server-rejected shapes that eval
// traces show surviving CLI validation and costing a full network round
// trip. Structure is checked leniently to match the pass-through contract:
// any element that isn't the expected shape is skipped, not rejected.
func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
for r, rowRaw := range cells {
row, ok := rowRaw.([]interface{})
@@ -546,10 +642,18 @@ func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
if !ok {
continue
}
if bs, ok := cell["border_styles"].(map[string]interface{}); ok {
expandBorderAllShorthand(bs)
}
st, ok := cell["cell_styles"].(map[string]interface{})
if !ok {
continue
}
if _, misNested := st["border_styles"]; misNested {
return common.ValidationErrorf(
"%s[%d][%d].cell_styles.border_styles is not valid — border_styles is a top-level cell field, a sibling of cell_styles; move it up one level",
path, r, c)
}
if err := normalizeCellStyleAliases(st, fmt.Sprintf("%s[%d][%d].cell_styles", path, r, c)); err != nil {
return err
}
@@ -558,8 +662,29 @@ func normalizeTypedCellsStyleAliases(cells []interface{}, path string) error {
return nil
}
// expandBorderAllShorthand rewrites the "all" side shorthand — habitual from
// Excel / openpyxl vocabulary, rejected by the backend — into the four
// explicit sides, in place. An explicitly set side wins over the shorthand.
// Applied on both the typed --cells path and the --styles path, so batch
// sub-ops get the same rewrite as standalone calls.
func expandBorderAllShorthand(border map[string]interface{}) {
all, ok := border["all"]
if !ok {
return
}
for _, side := range []string{"top", "bottom", "left", "right"} {
if _, exists := border[side]; !exists {
border[side] = all
}
}
delete(border, "all")
}
// borderStylesFromFlag parses --border-styles as a JSON object (top/bottom/
// left/right with style sub-objects). Returns nil when the flag is empty.
// left/right with style sub-objects), expanding the "all" side shorthand the
// same as the typed --cells and --styles paths so +cells-set-style /
// +cells-batch-set-style don't ship {"all":…} for the backend to reject.
// Returns nil when the flag is empty.
func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
if runtime.Str("border-styles") == "" {
return nil, nil
@@ -572,6 +697,7 @@ func borderStylesFromFlag(runtime flagView) (map[string]interface{}, error) {
if !ok {
return nil, sheetsValidationForFlag("border-styles", "--border-styles must be a JSON object")
}
expandBorderAllShorthand(m)
return m, nil
}

View File

@@ -0,0 +1,209 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"encoding/json"
"strings"
"testing"
)
// TestWrapLoneCellObject pins the auto-wrap contract: a bare cell object —
// the classic missing-[[…]] shape agents produce for a 1×1 write — is
// rewritten to [[cell]]; anything whose meaning is not beyond doubt stays
// untouched for the schema validator to prescribe.
func TestWrapLoneCellObject(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
wrapped bool
}{
{"lone value cell", `{"value":"hi"}`, true},
{"lone formula cell with styles", `{"formula":"=SUM(A1:A3)","cell_styles":{"font_weight":"bold"}}`, true},
{"unknown key stays", `{"value":"hi","range":"A1"}`, false},
{"array of cells stays (row vs column ambiguous)", `[{"value":"a"},{"value":"b"}]`, false},
{"proper 2D array stays", `[[{"value":"a"}]]`, false},
{"empty object stays", `{}`, false},
{"scalar stays", `"hi"`, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var v interface{}
if err := json.Unmarshal([]byte(tc.in), &v); err != nil {
t.Fatalf("bad fixture: %v", err)
}
out := wrapLoneCellObject(v)
_, isWrapped := out.([]interface{})
_, wasArray := v.([]interface{})
if tc.wrapped && (!isWrapped || wasArray) {
t.Errorf("expected wrap to [[cell]], got %#v", out)
}
if !tc.wrapped && !wasArray && isWrapped {
t.Errorf("expected no wrap, got %#v", out)
}
if tc.wrapped {
rows, _ := out.([]interface{})
if len(rows) != 1 {
t.Fatalf("want 1 row, got %d", len(rows))
}
cells, _ := rows[0].([]interface{})
if len(cells) != 1 {
t.Fatalf("want 1 cell, got %d", len(cells))
}
}
})
}
}
// TestCellObjectKeys_MatchEmbeddedSchema drift-guards the hardcoded cell
// vocabulary against the embedded +cells-set --cells schema: if the spec
// repo adds or removes a cell property, this fails and cellObjectKeys must
// be updated (an outdated set only narrows the auto-wrap, but silently
// narrowing is still drift).
func TestCellObjectKeys_MatchEmbeddedSchema(t *testing.T) {
t.Parallel()
idx, err := loadFlagSchemas()
if err != nil {
t.Fatalf("loadFlagSchemas: %v", err)
}
raw, ok := idx.Flags["+cells-set"]["cells"]
if !ok {
t.Fatal("embedded schema for +cells-set --cells missing")
}
var schema schemaProperty
if err := json.Unmarshal(raw, &schema); err != nil {
t.Fatalf("unmarshal schema: %v", err)
}
cell := schema.Items
if cell != nil && cell.Items != nil {
cell = cell.Items
}
if cell == nil || len(cell.Properties) == 0 {
t.Fatal("schema shape changed: expected array→array→object with properties")
}
for k := range cell.Properties {
if _, ok := cellObjectKeys[k]; !ok {
t.Errorf("schema property %q missing from cellObjectKeys", k)
}
}
for k := range cellObjectKeys {
if _, ok := cell.Properties[k]; !ok {
t.Errorf("cellObjectKeys has %q which the schema no longer declares", k)
}
}
}
// TestCellsSet_LoneCellObjectAutoWraps runs the mounted path end-to-end: the
// eval-trace failure shape (--cells with a bare object) now dry-runs clean
// instead of failing "expected type array, got object".
func TestCellsSet_LoneCellObjectAutoWraps(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1",
"--cells", `{"value":"hello"}`,
"--dry-run",
})
if err != nil {
t.Fatalf("lone cell object should auto-wrap to [[cell]], got: %v", err)
}
if !strings.Contains(stdout, "hello") {
t.Errorf("dry-run body should carry the cell value, got %q", stdout)
}
}
// TestTablePut_SheetsDecodeHints pins the two decode-failure prescriptions:
// wrong JSON kind inlines the expected shape; mangled JSON steers to
// stdin/@file.
func TestTablePut_SheetsDecodeHints(t *testing.T) {
t.Parallel()
t.Run("type mismatch inlines skeleton", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+table-put")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"s","columns":[{"name":"a"}],"data":[]}]}`,
"--dry-run",
})
ve := requireValidation(t, err, "--sheets: invalid JSON")
for _, want := range []string{"expected shape:", `"columns":["City","Revenue"]`, `"dtypes":{"Revenue":"float64"}`} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
t.Run("syntax error steers to stdin or @file", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+table-put")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheets", `{"sheets":[)`,
"--dry-run",
})
ve := requireValidation(t, err, "--sheets: invalid JSON")
for _, want := range []string{"stdin", "@./payload.json"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
}
// TestNormalizeChartHexColors pins the '#' prefixing on bare hex color
// values (eval V2U024: bars.color "4472C4" rejected server-side) and the
// pass-through of everything else, including the parseJSONFlag wiring for
// the batch sub-op path.
func TestNormalizeChartHexColors(t *testing.T) {
t.Parallel()
props := map[string]interface{}{
"plotArea": map[string]interface{}{
"plot": map[string]interface{}{
"series": []interface{}{
map[string]interface{}{"bars": map[string]interface{}{"color": "4472C4"}},
map[string]interface{}{"line": map[string]interface{}{"color": "#ED7D31"}},
map[string]interface{}{"area": map[string]interface{}{"color": "rgba(1,2,3,0.5)"}},
map[string]interface{}{"font_color": "ED7D31AA", "label": "not a color 4472C4"},
},
},
},
}
normalizeChartHexColors(props)
series := props["plotArea"].(map[string]interface{})["plot"].(map[string]interface{})["series"].([]interface{})
if got := series[0].(map[string]interface{})["bars"].(map[string]interface{})["color"]; got != "#4472C4" {
t.Errorf("bare hex should gain #, got %v", got)
}
if got := series[1].(map[string]interface{})["line"].(map[string]interface{})["color"]; got != "#ED7D31" {
t.Errorf("already-prefixed color must not change, got %v", got)
}
if got := series[2].(map[string]interface{})["area"].(map[string]interface{})["color"]; got != "rgba(1,2,3,0.5)" {
t.Errorf("rgba color must not change, got %v", got)
}
last := series[3].(map[string]interface{})
if got := last["font_color"]; got != "#ED7D31AA" {
t.Errorf("8-digit hex on a *_color key should gain #, got %v", got)
}
if got := last["label"]; got != "not a color 4472C4" {
t.Errorf("non-color key must not change, got %v", got)
}
// Wiring: a +chart-create sub-op style view routes through parseJSONFlag
// and picks up the normalizer.
fv := newMapFlagViewForCommand("+chart-create", map[string]interface{}{
"properties": map[string]interface{}{"title": map[string]interface{}{"font_color": "112233"}},
})
out, err := parseJSONFlag(fv, "properties")
if err != nil {
t.Fatalf("parseJSONFlag: %v", err)
}
title := out.(map[string]interface{})["title"].(map[string]interface{})
if title["font_color"] != "#112233" {
t.Errorf("parseJSONFlag should apply the chart color normalizer, got %v", title["font_color"])
}
}

View File

@@ -83,6 +83,7 @@ var BatchUpdate = common.Shortcut{
return nil
},
Tips: []string{
"high-risk-write: always pass --yes (or --dry-run to preview) — without it the call exits 10 asking for confirmation.",
"Default is strict transaction — any sub-tool failure rolls the whole batch back. Pass --continue-on-error to keep partial successes.",
"Each sub-op is {shortcut, input}. Do NOT pass input.operation (implied by shortcut name) or input.excel_id / input.url (set at the +batch-update top level).",
},
@@ -160,6 +161,10 @@ var CellsBatchSetStyle = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-batch-set-style"),
Tips: []string{
`Example: lark-cli sheets +cells-batch-set-style --url <URL> --ranges '["Sheet1!A1:B2","汇总!C1:C9"]' --font-weight bold`,
"Every range carries its sheet-NAME prefix (Sheet1!A1:B2, not a sheet_id) — there is no --sheet-id / --sheet-name flag here.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err

View File

@@ -0,0 +1,605 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"regexp"
"strconv"
"strings"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
var chartHexColorPattern = regexp.MustCompile(`^#?[0-9A-Fa-f]{6}([0-9A-Fa-f]{2})?$`)
var chartSemanticConfigFlags = []string{
"title",
"subtitle",
"legend-position",
"x-axis-title",
"y-axis-title",
"secondary-y-axis-title",
"x-axis-label-angle",
"y-axis-label-angle",
"data-labels",
"data-label-position",
"stack",
"color-palette",
}
// ChartCreateBasic creates a complete server-side chart snapshot from a chart
// type and a rectangular source range. The CLI only forwards semantic input;
// it deliberately does not own or duplicate the full chart snapshot template.
var ChartCreateBasic = common.Shortcut{
Service: "sheets",
Command: "+chart-create-basic",
Description: "Create a basic chart from a chart type and data range; the server builds and validates the full snapshot.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+chart-create-basic"),
PostMount: configureChartSemanticCommand,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = chartCreateBasicInput(runtime, token, sheetID, sheetName)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := chartCreateBasicInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := chartCreateBasicInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// ChartConfigUpdate updates the common chart settings that repeatedly caused
// full-snapshot retries in eval traces. Advanced per-series and marker styling
// remains on +chart-update --properties.
var ChartConfigUpdate = common.Shortcut{
Service: "sheets",
Command: "+chart-config-update",
Description: "Update common chart titles, axes, legend, labels, stacking, smoothing, or chart-level colors without sending a snapshot.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+chart-config-update"),
PostMount: configureChartSemanticCommand,
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = chartConfigUpdateInput(runtime, token, sheetID, sheetName)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := chartConfigUpdateInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := chartConfigUpdateInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
// ChartDataUpdate rebinds an existing chart to a new source range. The server
// reads the current snapshot, rebuilds its data mapping, and preserves the
// chart's layout and visual configuration.
var ChartDataUpdate = common.Shortcut{
Service: "sheets",
Command: "+chart-data-update",
Description: "Update an existing chart's data range or direction while preserving its layout and visual configuration.",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+chart-data-update"),
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetToken(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
_, err = chartDataUpdateInput(runtime, token, sheetID, sheetName)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
input, _ := chartDataUpdateInput(runtime, token, sheetID, sheetName)
return invokeToolDryRun(token, ToolKindWrite, "manage_chart_object", input)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, err := resolveSpreadsheetTokenExec(runtime)
if err != nil {
return err
}
sheetID, sheetName, err := resolveSheetSelector(runtime)
if err != nil {
return err
}
input, err := chartDataUpdateInput(runtime, token, sheetID, sheetName)
if err != nil {
return err
}
out, err := callTool(ctx, runtime, token, ToolKindWrite, "manage_chart_object", input)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
func chartCreateBasicInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
chartType := strings.TrimSpace(rt.Str("chart-type"))
if chartType == "" {
return nil, sheetsValidationForFlag("chart-type", "--chart-type is required")
}
dataRange := strings.TrimSpace(rt.Str("data-range"))
if dataRange == "" {
return nil, sheetsValidationForFlag("data-range", "--data-range is required")
}
direction := rt.Str("data-direction")
if direction == "" {
direction = "column"
}
normalizedDataRange, dimensionCount, dataPointCount, err := normalizeBasicChartDataRanges(dataRange, direction)
if err != nil {
return nil, err
}
if dimensionCount < 2 || dataPointCount < 2 {
return nil, sheetsValidationForFlag("data-range", "--data-range must provide at least 2 data points and 2 dimensions")
}
if chartType == "combo" && dimensionCount < 3 {
return nil, sheetsValidationForFlag("data-range", "combo chart requires at least 3 rows or columns along --data-direction")
}
basic := map[string]interface{}{
"chart_type": chartType,
"data_range": normalizedDataRange,
}
if rt.Changed("data-direction") {
basic["data_direction"] = rt.Str("data-direction")
}
if err := validateChartColorFlags(rt); err != nil {
return nil, err
}
if err := validateChartSemanticEnums(rt); err != nil {
return nil, err
}
addChartSemanticConfig(rt, basic)
if rt.Changed("anchor-cell") {
anchor := strings.TrimSpace(rt.Str("anchor-cell"))
_, row, ok := splitCellRef(anchor)
if !ok {
return nil, sheetsValidationForFlag("anchor-cell", "--anchor-cell must be a single A1 cell such as F2")
}
colEnd := 0
for colEnd < len(anchor) && ((anchor[colEnd] >= 'A' && anchor[colEnd] <= 'Z') || (anchor[colEnd] >= 'a' && anchor[colEnd] <= 'z')) {
colEnd++
}
basic["position"] = map[string]interface{}{"row": row, "col": strings.ToUpper(anchor[:colEnd])}
}
widthChanged := rt.Changed("width")
heightChanged := rt.Changed("height")
if widthChanged != heightChanged {
return nil, common.ValidationErrorf("--width and --height must be provided together").WithParams(
sheetsInvalidParam("width", "must be paired with --height"),
sheetsInvalidParam("height", "must be paired with --width"),
)
}
if widthChanged {
if rt.Int("width") < 10 || rt.Int("height") < 10 {
return nil, common.ValidationErrorf("--width and --height must be at least 10")
}
basic["size"] = map[string]interface{}{"width": rt.Int("width"), "height": rt.Int("height")}
}
input := map[string]interface{}{"excel_id": token, "operation": "create", "basic_chart": basic}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, err
}
return input, nil
}
func chartConfigUpdateInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
chartID := strings.TrimSpace(rt.Str("chart-id"))
if chartID == "" {
return nil, sheetsValidationForFlag("chart-id", "--chart-id is required")
}
updates := map[string]interface{}{}
if err := validateChartColorFlags(rt); err != nil {
return nil, err
}
if err := validateChartSemanticEnums(rt); err != nil {
return nil, err
}
addChartSemanticConfig(rt, updates)
if len(updates) == 0 {
return nil, common.ValidationErrorf("at least one chart configuration flag is required")
}
input := map[string]interface{}{
"excel_id": token,
"operation": "update",
"chart_id": chartID,
"config_updates": updates,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, err
}
return input, nil
}
func chartDataUpdateInput(rt flagView, token, sheetID, sheetName string) (map[string]interface{}, error) {
if err := requireSheetSelector(sheetID, sheetName); err != nil {
return nil, err
}
chartID := strings.TrimSpace(rt.Str("chart-id"))
if chartID == "" {
return nil, sheetsValidationForFlag("chart-id", "--chart-id is required")
}
dataRange := strings.TrimSpace(rt.Str("data-range"))
if dataRange == "" {
return nil, sheetsValidationForFlag("data-range", "--data-range is required")
}
ranges, err := splitChartDataRanges(dataRange)
if err != nil {
return nil, sheetsValidationForFlag("data-range", "invalid --data-range %q: %v", dataRange, err)
}
explicitSheet := ""
for _, value := range ranges {
item, parseErr := parseChartDataRange(value)
if parseErr != nil {
return nil, sheetsValidationForFlag("data-range", "invalid --data-range item %q: %v", value, parseErr)
}
if item.sheet != "" {
if explicitSheet != "" && item.sheet != explicitSheet {
return nil, sheetsValidationForFlag("data-range", "all --data-range items must belong to the same sheet")
}
explicitSheet = item.sheet
}
}
updates := map[string]interface{}{"data_range": dataRange}
if rt.Changed("data-direction") {
updates["data_direction"] = rt.Str("data-direction")
}
dim1Index := 1
if rt.Changed("dim1-index") {
dim1Index = rt.Int("dim1-index")
if dim1Index < 1 {
return nil, sheetsValidationForFlag("dim1-index", "--dim1-index must be a positive 1-based index")
}
updates["dim1_index"] = dim1Index
}
if rt.Changed("dim2-indexes") {
dim2Indexes, parseErr := parseChartDim2Indexes(rt.Str("dim2-indexes"))
if parseErr != nil {
return nil, sheetsValidationForFlag("dim2-indexes", "%v", parseErr)
}
for _, index := range dim2Indexes {
if index == dim1Index {
return nil, sheetsValidationForFlag(
"dim2-indexes",
"--dim2-indexes must not contain the dim1 index %d",
dim1Index,
)
}
}
updates["dim2_indexes"] = dim2Indexes
}
input := map[string]interface{}{
"excel_id": token,
"operation": "update",
"chart_id": chartID,
"data_updates": updates,
}
sheetSelectorForToolInput(input, sheetID, sheetName)
if err := validateInputAgainstSchema(rt, input); err != nil {
return nil, err
}
return input, nil
}
func parseChartDim2Indexes(raw string) ([]int, error) {
parts := strings.Split(raw, ",")
indexes := make([]int, 0, len(parts))
seen := make(map[int]struct{}, len(parts))
for _, part := range parts {
value := strings.TrimSpace(part)
if value == "" {
return nil, common.ValidationErrorf("--dim2-indexes must be a comma-separated list of positive 1-based indexes")
}
index, err := strconv.Atoi(value)
if err != nil || index < 1 {
return nil, common.ValidationErrorf("--dim2-indexes must contain only positive 1-based indexes")
}
if _, exists := seen[index]; exists {
return nil, common.ValidationErrorf("--dim2-indexes must not contain duplicate index %d", index)
}
seen[index] = struct{}{}
indexes = append(indexes, index)
}
return indexes, nil
}
type chartDataRange struct {
sheet string
row, col int
rowCount, colCount int
}
func normalizeBasicChartDataRanges(dataRange, direction string) (normalized string, dimensionCount, dataPointCount int, err error) {
ranges, err := splitChartDataRanges(dataRange)
if err != nil {
return "", 0, 0, sheetsValidationForFlag("data-range", "invalid --data-range %q: %v", dataRange, err)
}
parsed := make([]chartDataRange, 0, len(ranges))
for _, value := range ranges {
item, parseErr := parseChartDataRange(value)
if parseErr != nil {
return "", 0, 0, sheetsValidationForFlag("data-range", "invalid --data-range item %q: %v", value, parseErr)
}
parsed = append(parsed, item)
}
first := parsed[0]
explicitSheet := ""
spans := make([][2]int, 0, len(parsed))
aligned := true
minRow, minCol := first.row, first.col
maxRow, maxCol := first.row+first.rowCount, first.col+first.colCount
for _, item := range parsed {
if item.sheet != "" {
if explicitSheet != "" && item.sheet != explicitSheet {
return "", 0, 0, sheetsValidationForFlag("data-range", "all --data-range items must belong to the same sheet")
}
explicitSheet = item.sheet
}
if direction == "row" {
if item.col != first.col || item.colCount != first.colCount {
aligned = false
}
dimensionCount += item.rowCount
spans = append(spans, [2]int{item.row, item.row + item.rowCount})
} else {
if item.row != first.row || item.rowCount != first.rowCount {
aligned = false
}
dimensionCount += item.colCount
spans = append(spans, [2]int{item.col, item.col + item.colCount})
}
minRow = min(minRow, item.row)
minCol = min(minCol, item.col)
maxRow = max(maxRow, item.row+item.rowCount)
maxCol = max(maxCol, item.col+item.colCount)
}
overlapping := false
for i, current := range spans {
for j := 0; j < i; j++ {
if current[0] < spans[j][1] && spans[j][0] < current[1] {
overlapping = true
}
}
}
normalized = strings.Join(ranges, ",")
if len(ranges) > 1 && (!aligned || overlapping) {
prefix := ""
if explicitSheet != "" {
prefix = explicitSheet + "!"
}
normalized = prefix + columnIndexToLetter(minCol) + strconv.Itoa(minRow+1) + ":" + columnIndexToLetter(maxCol-1) + strconv.Itoa(maxRow)
dimensionCount = maxCol - minCol
dataPointCount = maxRow - minRow
if direction == "row" {
dimensionCount, dataPointCount = dataPointCount, dimensionCount
}
return normalized, dimensionCount, dataPointCount, nil
}
if direction == "row" {
dataPointCount = first.colCount
} else {
dataPointCount = first.rowCount
}
return normalized, dimensionCount, dataPointCount, nil
}
func splitChartDataRanges(value string) ([]string, error) {
var ranges []string
start := 0
inQuote := false
for i := 0; i <= len(value); i++ {
if i < len(value) && value[i] == '\'' {
if inQuote && i+1 < len(value) && value[i+1] == '\'' {
i++
} else {
inQuote = !inQuote
}
}
if i == len(value) || (value[i] == ',' && !inQuote) {
part := strings.TrimSpace(value[start:i])
if part == "" {
return nil, common.ValidationErrorf("range list contains an empty item")
}
ranges = append(ranges, part)
start = i + 1
}
}
if inQuote {
return nil, common.ValidationErrorf("unterminated quoted sheet name")
}
return ranges, nil
}
func parseChartDataRange(value string) (chartDataRange, error) {
item := chartDataRange{}
ref := strings.TrimSpace(value)
if bang := strings.LastIndex(ref, "!"); bang >= 0 {
item.sheet = strings.TrimSpace(ref[:bang])
ref = strings.TrimSpace(ref[bang+1:])
}
parts := strings.SplitN(ref, ":", 2)
if len(parts) != 2 {
return item, common.ValidationErrorf("expected a rectangular A1 range such as A1:C10")
}
startCol, startRow, startOK := splitCellRef(parts[0])
endCol, endRow, endOK := splitCellRef(parts[1])
if !startOK || !endOK || endCol < startCol || endRow < startRow {
return item, common.ValidationErrorf("expected a rectangular A1 range such as A1:C10")
}
item.row, item.col = startRow, startCol
item.rowCount, item.colCount = endRow-startRow+1, endCol-startCol+1
return item, nil
}
func configureChartSemanticCommand(cmd *cobra.Command) {
if cmd.Flags().Lookup("stacked") == nil {
cmd.Flags().Bool("stacked", false, "compatibility alias for --stack normal")
_ = cmd.Flags().MarkHidden("stacked")
}
originalArgs := cmd.Args
cmd.Args = func(cmd *cobra.Command, args []string) error {
if len(args) == 1 && cmd.Flags().Changed("smooth") && (args[0] == "true" || args[0] == "false") {
return cmd.Flags().Set("smooth", args[0])
}
return originalArgs(cmd, args)
}
cmd.SetFlagErrorFunc(func(_ *cobra.Command, err error) error {
message := err.Error()
if strings.Contains(message, "unknown flag: --stacked") {
return sheetsValidationForFlag("stacked", "--stacked is not supported; use --stack normal (or --stack percent for 100%% stacking)")
}
return err
})
}
func addChartSemanticConfig(rt flagView, out map[string]interface{}) {
for _, flag := range chartSemanticConfigFlags {
if !rt.Changed(flag) {
continue
}
key := strings.ReplaceAll(flag, "-", "_")
if flag == "x-axis-label-angle" || flag == "y-axis-label-angle" {
out[key] = rt.Int(flag)
} else if flag == "data-labels" && rt.Str(flag) == "category_percentage" {
out[key] = "value_percentage"
} else {
out[key] = rt.Str(flag)
}
}
if rt.Changed("stacked") {
out["stack"] = "normal"
}
if rt.Changed("smooth") {
out["smooth"] = rt.Bool("smooth")
}
if rt.Changed("colors") {
out["colors"] = normalizedChartColors(rt)
}
}
func validateChartSemanticEnums(rt flagView) error {
if rt.Changed("stack") && rt.Changed("stacked") {
return common.ValidationErrorf("--stack and --stacked are mutually exclusive").WithParams(
sheetsInvalidParam("stack", "cannot be used with --stacked"),
sheetsInvalidParam("stacked", "cannot be used with --stack"),
)
}
return nil
}
func validateChartColorFlags(rt flagView) error {
if rt.Changed("color-palette") && rt.Changed("colors") {
return common.ValidationErrorf("--color-palette and --colors are mutually exclusive").WithParams(
sheetsInvalidParam("color-palette", "cannot be used with --colors"),
sheetsInvalidParam("colors", "cannot be used with --color-palette"),
)
}
if rt.Changed("colors") {
colors := normalizedChartColors(rt)
if len(colors) < 2 {
return sheetsValidationForFlag("colors", "--colors must contain at least two hex colors")
}
for _, color := range colors {
if !chartHexColorPattern.MatchString(color) {
return sheetsValidationForFlag("colors", "--colors contains invalid hex color %q", color)
}
}
}
return nil
}
func normalizedChartColors(rt flagView) []string {
raw := rt.StrSlice("colors")
colors := make([]string, len(raw))
for i := range raw {
colors[i] = strings.TrimSpace(raw[i])
}
return colors
}

View File

@@ -0,0 +1,346 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import "testing"
func TestChartCreateBasic_AllTypes(t *testing.T) {
t.Parallel()
types := []string{"column", "bar", "line", "area", "pie", "scatter", "combo", "radar"}
for _, chartType := range types {
chartType := chartType
t.Run(chartType, func(t *testing.T) {
t.Parallel()
rangeValue := "A1:C4"
if chartType == "combo" {
rangeValue = "A1:D4"
}
body := parseDryRunBody(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", chartType,
"--data-range", rangeValue,
})
input := decodeToolInput(t, body, "manage_chart_object")
if input["operation"] != "create" {
t.Fatalf("operation = %v, want create", input["operation"])
}
if _, ok := input["properties"]; ok {
t.Fatal("semantic create must not send properties")
}
basic, _ := input["basic_chart"].(map[string]interface{})
if basic["chart_type"] != chartType || basic["data_range"] != rangeValue {
t.Fatalf("basic_chart = %#v", basic)
}
})
}
}
func TestChartCreateBasic_ConfigAndPlacement(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", "line",
"--data-range", "A1:C4",
"--anchor-cell", "f2",
"--width", "640",
"--height", "360",
"--title", "Trend",
"--legend-position", "bottom",
"--smooth=false",
"--data-direction", "row",
"--color-palette", "brandColorSeries@v2",
})
input := decodeToolInput(t, body, "manage_chart_object")
basic, _ := input["basic_chart"].(map[string]interface{})
position, _ := basic["position"].(map[string]interface{})
size, _ := basic["size"].(map[string]interface{})
if position["col"] != "F" || position["row"] != float64(1) {
t.Errorf("position = %#v, want F2 as zero-based row 1", position)
}
if size["width"] != float64(640) || size["height"] != float64(360) {
t.Errorf("size = %#v", size)
}
if basic["title"] != "Trend" || basic["legend_position"] != "bottom" || basic["smooth"] != false ||
basic["data_direction"] != "row" || basic["color_palette"] != "brandColorSeries@v2" {
t.Errorf("semantic config = %#v", basic)
}
}
func TestChartCreateBasic_MultipleAlignedRanges(t *testing.T) {
t.Parallel()
rangeValue := "'Data, 2026'!A1:A10,'Data, 2026'!K1:L10"
body := parseDryRunBody(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", "line",
"--data-range", rangeValue,
})
input := decodeToolInput(t, body, "manage_chart_object")
basic := input["basic_chart"].(map[string]interface{})
if basic["data_range"] != rangeValue {
t.Fatalf("basic_chart.data_range = %v, want %q", basic["data_range"], rangeValue)
}
}
func TestChartCreateBasic_MergesMisalignedOrOverlappingRanges(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
expected string
}{
{name: "separated rows", input: "'Sheet1'!A1:M1,'Sheet1'!A3:M3", expected: "'Sheet1'!A1:M3"},
{name: "overlapping columns", input: "A1:B10,B1:C10", expected: "A1:C10"},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", "line",
"--data-range", tt.input,
})
input := decodeToolInput(t, body, "manage_chart_object")
basic := input["basic_chart"].(map[string]interface{})
if basic["data_range"] != tt.expected {
t.Fatalf("basic_chart.data_range = %v, want %q", basic["data_range"], tt.expected)
}
})
}
}
func TestChartCreateBasic_RejectsCrossSheetRanges(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, ChartCreateBasic, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-type", "line",
"--data-range", "'A'!A1:A10,'B'!C1:D10",
})
if err == nil {
t.Fatal("expected cross-sheet ranges to fail")
}
}
func TestChartSemanticShortcuts_InBatchUpdate(t *testing.T) {
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[
{"shortcut":"+chart-create-basic","input":{"sheet-id":"sh1","chart-type":"column","data-range":"A1:C10","title":"Sales"}},
{"shortcut":"+chart-create-basic","input":{"sheet-id":"sh1","chart-type":"line","data-range":"E1:G10","title":"Trend"}}
]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops := input["operations"].([]interface{})
if len(ops) != 2 {
t.Fatalf("operations len = %d, want 2", len(ops))
}
for i, op := range ops {
item := op.(map[string]interface{})
if item["tool_name"] != "manage_chart_object" {
t.Fatalf("operations[%d].tool_name = %v", i, item["tool_name"])
}
chartInput := item["input"].(map[string]interface{})
if chartInput["operation"] != "create" {
t.Fatalf("operations[%d].input.operation = %v", i, chartInput["operation"])
}
if _, ok := chartInput["basic_chart"].(map[string]interface{}); !ok {
t.Fatalf("operations[%d].input.basic_chart = %#v", i, chartInput["basic_chart"])
}
}
}
func TestChartConfigUpdate_PartialFields(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartConfigUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--y-axis-title", "Revenue",
"--stack", "percent",
"--smooth=false",
"--colors", "#112233,#445566",
})
input := decodeToolInput(t, body, "manage_chart_object")
if input["operation"] != "update" || input["chart_id"] != "chart-1" {
t.Fatalf("input = %#v", input)
}
if _, ok := input["properties"]; ok {
t.Fatal("semantic update must not send properties")
}
updates, _ := input["config_updates"].(map[string]interface{})
if updates["y_axis_title"] != "Revenue" || updates["stack"] != "percent" || updates["smooth"] != false {
t.Errorf("config_updates = %#v", updates)
}
colors, _ := updates["colors"].([]interface{})
if len(colors) != 2 || colors[0] != "#112233" || colors[1] != "#445566" {
t.Errorf("config_updates.colors = %#v", updates["colors"])
}
}
func TestChartConfigUpdate_SpacedSmoothBool(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartConfigUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--smooth", "false",
})
input := decodeToolInput(t, body, "manage_chart_object")
updates := input["config_updates"].(map[string]interface{})
if updates["smooth"] != false {
t.Fatalf("config_updates.smooth = %v, want false", updates["smooth"])
}
}
func TestChartSemanticShortcuts_CompatibleAliases(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartConfigUpdate, []string{
"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--stacked",
})
updates := decodeToolInput(t, body, "manage_chart_object")["config_updates"].(map[string]interface{})
if updates["stack"] != "normal" {
t.Fatalf("--stacked normalized stack = %v, want normal", updates["stack"])
}
body = parseDryRunBody(t, ChartConfigUpdate, []string{
"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-labels", "category_percentage",
})
updates = decodeToolInput(t, body, "manage_chart_object")["config_updates"].(map[string]interface{})
if updates["data_labels"] != "value_percentage" {
t.Fatalf("data-labels normalized value = %v, want value_percentage", updates["data_labels"])
}
}
func TestChartSemanticShortcuts_CompatibleAliasesInBatch(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL,
"--operations", `[{"shortcut":"+chart-config-update","input":{"sheet_id":"sh1","chart_id":"chart-1","stacked":true,"data_labels":"category_percentage","smooth":false}}]`,
"--yes",
})
input := decodeToolInput(t, body, "batch_update")
ops := input["operations"].([]interface{})
chartInput := ops[0].(map[string]interface{})["input"].(map[string]interface{})
updates := chartInput["config_updates"].(map[string]interface{})
if updates["stack"] != "normal" || updates["data_labels"] != "value_percentage" || updates["smooth"] != false {
t.Fatalf("batch config_updates = %#v", updates)
}
}
func TestChartDataUpdate_PreservesSnapshotServerSide(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartDataUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--data-range", "'Sheet1'!A1:M6",
})
input := decodeToolInput(t, body, "manage_chart_object")
if input["operation"] != "update" || input["chart_id"] != "chart-1" {
t.Fatalf("input = %#v", input)
}
if _, ok := input["properties"]; ok {
t.Fatal("semantic data update must not send properties")
}
updates, _ := input["data_updates"].(map[string]interface{})
if updates["data_range"] != "'Sheet1'!A1:M6" {
t.Errorf("data_updates = %#v", updates)
}
if _, ok := updates["data_direction"]; ok {
t.Errorf("omitted --data-direction must preserve the server-side direction: %#v", updates)
}
}
func TestChartDataUpdate_ExplicitDirectionAndMultipleRanges(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartDataUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--data-range", "'Sheet1'!A1:A10,'Sheet1'!K1:L10",
"--data-direction", "column",
})
input := decodeToolInput(t, body, "manage_chart_object")
updates := input["data_updates"].(map[string]interface{})
if updates["data_range"] != "'Sheet1'!A1:A10,'Sheet1'!K1:L10" || updates["data_direction"] != "column" {
t.Errorf("data_updates = %#v", updates)
}
}
func TestChartDataUpdate_ExplicitSeriesIndexes(t *testing.T) {
t.Parallel()
body := parseDryRunBody(t, ChartDataUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
"--data-range", "'Sheet1'!A1:M6",
"--dim1-index", "1",
"--dim2-indexes", "4, 8",
})
input := decodeToolInput(t, body, "manage_chart_object")
updates := input["data_updates"].(map[string]interface{})
if updates["dim1_index"] != float64(1) {
t.Errorf("data_updates.dim1_index = %#v", updates["dim1_index"])
}
indexes, _ := updates["dim2_indexes"].([]interface{})
if len(indexes) != 2 || indexes[0] != float64(4) || indexes[1] != float64(8) {
t.Errorf("data_updates.dim2_indexes = %#v", updates["dim2_indexes"])
}
}
func TestChartSemanticShortcuts_Validation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
args []string
}{
{name: "unsupported type", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "donut", "--data-range", "A1:C4"}},
{name: "invalid semantic enum", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--legend-position", "diagonal"}},
{name: "range too small", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:A4"}},
{name: "combo needs two series", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "combo", "--data-range", "A1:B4"}},
{name: "invalid direction", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--data-direction", "horizontal"}},
{name: "colors need two values", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--colors", "#112233"}},
{name: "palette and colors are exclusive", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--color-palette", "brandColorSeries@v2", "--colors", "#112233,#445566"}},
{name: "size must be paired", args: []string{"--url", testURL, "--sheet-id", testSheetID, "--chart-type", "line", "--data-range", "A1:C4", "--width", "640"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, _, err := runShortcutCapturingErr(t, ChartCreateBasic, tt.args)
if err == nil {
t.Fatal("expected validation error")
}
})
}
_, _, err := runShortcutCapturingErr(t, ChartConfigUpdate, []string{
"--url", testURL,
"--sheet-id", testSheetID,
"--chart-id", "chart-1",
})
if err == nil {
t.Fatal("expected config update with no changed field to fail")
}
for _, args := range [][]string{
{"--url", testURL, "--sheet-id", testSheetID, "--data-range", "A1:C4"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--data-direction", "horizontal"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--dim1-index", "0"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--dim2-indexes", "2,2"},
{"--url", testURL, "--sheet-id", testSheetID, "--chart-id", "chart-1", "--data-range", "A1:C4", "--dim2-indexes", "1,2"},
} {
_, _, err = runShortcutCapturingErr(t, ChartDataUpdate, args)
if err == nil {
t.Fatalf("expected chart data update validation error for args %#v", args)
}
}
}

View File

@@ -67,7 +67,7 @@ var CellsClear = common.Shortcut{
return nil
},
Tips: []string{
"high-risk-write — always preview with --dry-run; clear is not undoable.",
"high-risk-write — pass --yes to confirm (exit 10 without it), or preview with --dry-run first; clear is not undoable.",
"Can't delete an embedded pivot/chart by clearing cells — remove the object itself with +pivot-delete / +chart-delete.",
},
}
@@ -266,9 +266,13 @@ var ColsResize = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cols-resize"),
Validate: validateViaResize("column"),
DryRun: resizeDryRun("column"),
Execute: resizeExecute("column"),
Tips: []string{
"Example: lark-cli sheets +cols-resize --url <URL> --sheet-name Sheet1 --range A:C --width 120",
`Different widths per column in one atomic call: --widths '{"A":80,"C:E":120}'. Widths are pixels (px ≈ chars × 8 + 16), not Excel character units.`,
},
Validate: validateViaResize("column"),
DryRun: resizeDryRun("column"),
Execute: resizeExecute("column"),
}
// resizeDryRun / resizeExecute route a resize shortcut through resizeToolCall

View File

@@ -69,8 +69,7 @@ var CellsGet = common.Shortcut{
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
return emitReadResult(runtime, out)
},
}
@@ -88,17 +87,19 @@ func cellsGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName str
// read cap. Pin cell_limit very high so the tool's own default never binds
// before max_chars.
input["cell_limit"] = unboundedReadLimit
if n := runtime.Int("max-chars"); n > 0 {
if n, ok := maxCharsInput(runtime); ok {
input["max_chars"] = n
}
return input
}
// applyIncludeToCellsGet maps the fine-grained --include vocabulary to the
// tool's two coarse switches:
// tool's switches:
//
// - include_styles (bool) — toggled by "style" presence
// - value_render_option (enum) — "formula" → formula; otherwise omitted
// - include_truncation_info (bool) — toggled by "truncation" presence; makes
// the tool estimate and return per-cell isRowTruncated / isColTruncated
//
// "value", "comment", and "data_validation" are always returned by the tool
// per the schema; they have no dedicated knob today but are accepted in
@@ -119,6 +120,9 @@ func applyIncludeToCellsGet(input map[string]interface{}, include []string) {
if want["formula"] {
input["value_render_option"] = "formula"
}
if want["truncation"] {
input["include_truncation_info"] = true
}
}
// CsvGet wraps get_range_as_csv: pull one range as RFC 4180 CSV with optional
@@ -165,8 +169,7 @@ var CsvGet = common.Shortcut{
if !runtime.Bool("include-row-prefix") {
out = stripRowPrefixFromCsvOutput(out)
}
runtime.Out(out, nil)
return nil
return emitReadResult(runtime, out)
},
}
@@ -183,7 +186,7 @@ func csvGetInput(runtime *common.RuntimeContext, token, sheetID, sheetName strin
// read cap. Pin max_rows very high so the tool's own default never binds
// before max_chars.
input["max_rows"] = unboundedReadLimit
if n := runtime.Int("max-chars"); n > 0 {
if n, ok := maxCharsInput(runtime); ok {
input["max_chars"] = n
}
return input

View File

@@ -34,6 +34,22 @@ func TestReadDataShortcuts_DryRun(t *testing.T) {
"cell_limit": float64(unboundedReadLimit), // pinned high; --max-chars is the only cap
},
},
{
// --include truncation toggles include_truncation_info so the tool
// estimates and returns per-cell isRowTruncated / isColTruncated.
name: "+cells-get include=truncation",
sc: CellsGet,
args: []string{"--url", testURL, "--sheet-id", testSheetID, "--range", "A1:B2", "--include", "truncation"},
toolName: "get_cell_ranges",
wantInput: map[string]interface{}{
"excel_id": testToken,
"sheet_id": testSheetID,
"ranges": []interface{}{"A1:B2"},
"include_styles": false,
"include_truncation_info": true,
"cell_limit": float64(unboundedReadLimit),
},
},
{
// Canonical form: --sheet-id + bare --range. Aligned with
// +cells-get / +csv-get; before the e2e BUG-019 fix this

View File

@@ -128,7 +128,11 @@ var DimInsert = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-insert"),
Validate: validateViaInput(dimInsertInput),
Tips: []string{
"Example: lark-cli sheets +dim-insert --url <URL> --sheet-name Sheet1 --position 3 --count 2 --inherit-style before",
"Rows vs columns comes from --position alone: a row number (3) inserts rows, a column letter (C) inserts columns — there is no --dimension flag.",
},
Validate: validateViaInput(dimInsertInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
@@ -292,7 +296,10 @@ var DimFreeze = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+dim-freeze"),
Validate: validateViaInput(dimFreezeInput),
Tips: []string{
"Example: lark-cli sheets +dim-freeze --url <URL> --sheet-name Sheet1 --dimension row --count 2 (freezes the first 2 rows; --count 0 unfreezes)",
},
Validate: validateViaInput(dimFreezeInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)

View File

@@ -88,6 +88,7 @@ var TablePut = common.Shortcut{
return tablePutWrite(ctx, runtime, token, payload, styles)
},
Tips: []string{
`Example: lark-cli sheets +table-put --url <URL> --sheets '{"sheets":[{"name":"S1","columns":["City","Rev"],"dtypes":{"Rev":"float64"},"data":[["SH",1234.5]]}]}'`,
"Writes into an existing spreadsheet — pass --url or --spreadsheet-token. To create a new workbook first, use +workbook-create, then point --spreadsheet-token here.",
"Payload sheets are matched to existing sub-sheets by name (created when absent). Date columns take ISO yyyy-mm-dd strings — converted to real dates (serial + date format).",
"--styles applies number formats, colors, merges, and row/col sizes in the same call (same shape as +workbook-create's --styles): one styles item per written sheet, name-matched. Skips the separate +cells-set-style round-trip.",
@@ -241,6 +242,11 @@ func decoderExpectEOF(dec *json.Decoder) error {
return nil
}
// tablePutSheetsSkeleton is the one-line --sheets shape inlined on a decode
// error, so the retry needs no --print-schema round trip. Field vocabulary
// mirrors tableSheetIn.
const tablePutSheetsSkeleton = `{"sheets":[{"name":"Sheet1","columns":["City","Revenue"],"dtypes":{"Revenue":"float64"},"data":[["SH",123.4],["BJ",56.7]],"start_cell":"A1"}]}`
// parseTablePutPayload reads --sheets (JSON, supports @file / stdin) into a
// validated payload. UseNumber keeps numeric cells as json.Number so large
// integers (order IDs, etc.) survive without precision loss or scientific
@@ -259,7 +265,19 @@ func parseTablePutPayload(runtime flagView) (*tablePayload, error) {
Sheets []tableSheetIn `json:"sheets"`
}
if err := dec.Decode(&wire); err != nil {
return nil, common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
// Eval traces show two distinct decode failures that each burned
// retries: a field with the wrong JSON kind (columns as objects,
// dtypes as an array) — fixed by seeing the expected shape once —
// and shell-mangled JSON, fixed by moving the payload to stdin/@file.
verr := common.ValidationErrorf("--sheets: invalid JSON: %v", err).WithCause(err)
var ute *json.UnmarshalTypeError
if errors.As(err, &ute) {
return nil, verr.WithHint(
"expected shape: %s (columns is a flat string array; dtypes/formats are column-name-keyed maps; data is row-major)",
tablePutSheetsSkeleton)
}
return nil, verr.WithHint(
"if the payload contains formulas / quotes / commas, pass it via stdin (`--sheets - < file`) or a relative @file (`--sheets @./payload.json`)")
}
// Reject trailing non-whitespace after the first JSON value: json.Decoder
// accepts it silently (unlike json.Unmarshal), so e.g. `--sheets '{...} oops'`
@@ -1208,12 +1226,11 @@ var TableGet = common.Shortcut{
}
sheets = append(sheets, spec)
}
runtime.Out(map[string]interface{}{"sheets": sheets}, nil)
return nil
return emitReadResult(runtime, map[string]interface{}{"sheets": sheets})
},
Tips: []string{
"Output is the same shape +table-put consumes — pipe it back in, or load sheets[].rows into a DataFrame keyed by columns[].name.",
"Column types are inferred per column, but only when every non-empty cell agrees; a column mixing types (e.g. numbers + \"暂无\") degrades to string — lossless and round-trips cleanly. Numeric coercion of dirty cells is the caller's job (pandas to_numeric(errors=\"coerce\") on the string column).",
"Column types are inferred per column, but only when every non-empty cell agrees; a column mixing types (e.g. numbers + \"N/A\") degrades to string — lossless and round-trips cleanly. Numeric coercion of dirty cells is the caller's job (pandas to_numeric(errors=\"coerce\") on the string column).",
},
}
@@ -1354,11 +1371,18 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
"value_render_option": "raw_value",
"cell_limit": unboundedReadLimit,
}
// --max-chars binds the char budget (default 500000); --output-path lifts it
// to unbounded. Without this the tool applied its own ~50000 default and
// silently dropped rows past it with no signal in the +table-get output.
if n, ok := maxCharsInput(runtime); ok {
input["max_chars"] = n
}
sheetSelectorForToolInput(input, t.id, t.name)
out, err := callTool(ctx, runtime, token, ToolKindRead, "get_cell_ranges", input)
if err != nil {
return nil, err
}
truncated := cellRangesTruncated(out)
grid := extractCellGrid(out)
if len(grid) == 0 {
return emptySpec(), nil
@@ -1433,9 +1457,38 @@ func readSheetAsSpec(ctx context.Context, runtime *common.RuntimeContext, token
if len(formats) > 0 {
spec["formats"] = formats
}
// The tool clipped the read at max_chars: rows past the cap are missing from
// data. Surface it so the caller doesn't mistake a partial read for the whole
// sheet — re-run with --output-path (unlimited) or a higher --max-chars.
if truncated {
spec["truncated"] = true
spec["truncation_warning"] = "Result truncated by max_chars; rows past the cap were not returned. Best: re-run with --output-path to dump the whole sheet in one lossless pass (no cap). Alternatively raise --max-chars, or continue-read the remaining rows by passing --range for them — but that needs --no-header and you must reattach the header row and reconcile per-chunk dtypes yourself (this chunk's types were inferred from the rows returned here)."
}
return spec, nil
}
// cellRangesTruncated reports whether a get_cell_ranges response was clipped by
// max_chars — either the top-level has_more flag or the first range's truncated
// flag. Used by +table-get, whose spec output otherwise drops both signals.
func cellRangesTruncated(out interface{}) bool {
m, ok := out.(map[string]interface{})
if !ok {
return false
}
if hm, ok := m["has_more"].(bool); ok && hm {
return true
}
ranges, _ := m["ranges"].([]interface{})
if len(ranges) > 0 {
if r0, ok := ranges[0].(map[string]interface{}); ok {
if t, ok := r0["truncated"].(bool); ok {
return t
}
}
}
return false
}
// sheetCurrentRegion returns the A1 range covering the sheet's existing data,
// or "" for an empty sheet.
//
@@ -1522,7 +1575,7 @@ func readCellFormat(cell map[string]interface{}) string {
// inferColumnType decides a column's type from its data cells: a date
// number_format guides each cell's type, but a column is given a non-string type
// only when EVERY non-empty cell agrees. Real sheet columns often mix types (a
// number column with a stray "暂无", a date column with a bare count); declaring
// number column with a stray "N/A", a date column with a bare count); declaring
// number/date while a string value rides along makes the output inconsistent —
// it breaks round-trip back into +table-put (which rejects a string in a number
// column) and crashes pandas astype. So a mixed column degrades to string

View File

@@ -1140,7 +1140,7 @@ func TestTableGet_InferColumnType(t *testing.T) {
// Mixed number+text degrades to string (self-consistent: every value is then
// a string), so the column round-trips and pandas doesn't choke. Numeric
// coercion of the dirty cells is left to the caller (pandas to_numeric).
if typ, _ := inferColumnType(col(mk(100.0, ""), mk("暂无", ""), mk(200.0, "")), 0); typ != "string" {
if typ, _ := inferColumnType(col(mk(100.0, ""), mk("N/A", ""), mk(200.0, "")), 0); typ != "string" {
t.Errorf("mixed number+text col → %s, want string", typ)
}
// A bare number mixed into a date column must NOT stay date (would serial-

View File

@@ -405,7 +405,11 @@ var SheetCopy = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+sheet-copy"),
Validate: validateViaInput(sheetCopyInput),
Tips: []string{
"Example: lark-cli sheets +sheet-copy --url <URL> --sheet-name 数据源 --title 数据源-副本",
"--sheet-name / --sheet-id selects the SOURCE sheet; the copy's new name goes in --title.",
},
Validate: validateViaInput(sheetCopyInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
@@ -965,7 +969,11 @@ func parseWorkbookCreateStyles(runtime flagView) (*workbookCreateStylePayload, e
if len(items) != 1 {
return nil, common.ValidationErrorf("--styles.styles must contain exactly one item when using --values")
}
return parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
payload, probs := parseWorkbookCreateStyleItem(items[0], "--styles.styles[0]")
if err := joinStyleValidationErrors(probs); err != nil {
return nil, err
}
return payload, nil
}
// parseWorkbookCreateSheetStyles parses --styles for the typed --sheets path.
@@ -988,21 +996,28 @@ func parseWorkbookCreateSheetStyles(runtime flagView, payload *tablePayload) (*w
}
out := &workbookCreateSheetStyles{ByName: map[string]*workbookCreateStylePayload{}}
out.ByIndex = make([]*workbookCreateStylePayload, len(payload.Sheets))
var probs []error
for i, item := range items {
name, _ := item["name"].(string)
if strings.TrimSpace(name) == "" {
return nil, common.ValidationErrorf("--styles.styles[%d].name is required", i)
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name is required", i))
continue
}
if name != payload.Sheets[i].Name {
return nil, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name)
probs = append(probs, common.ValidationErrorf("--styles.styles[%d].name %q must match --sheets.sheets[%d].name %q", i, name, i, payload.Sheets[i].Name))
continue
}
style, err := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
if err != nil {
return nil, err
style, itemProbs := parseWorkbookCreateStyleItem(item, fmt.Sprintf("--styles.styles[%d]", i))
if len(itemProbs) > 0 {
probs = append(probs, itemProbs...)
continue
}
out.ByIndex[i] = style
out.ByName[name] = style
}
if err := joinStyleValidationErrors(probs); err != nil {
return nil, err
}
return out, nil
}
@@ -1030,182 +1045,268 @@ func parseWorkbookCreateStylesItems(v interface{}) ([]map[string]interface{}, er
return items, nil
}
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, error) {
// parseWorkbookCreateStyleItem parses one --styles item. All four sections
// are validated even after one fails, and every issue is returned in the
// slice: eval traces show agents fixing --styles errors one round trip per
// error (border side, then row_sizes.type, then size…) because only the
// first was ever reported.
func parseWorkbookCreateStyleItem(item map[string]interface{}, path string) (*workbookCreateStylePayload, []error) {
payload := &workbookCreateStylePayload{}
var err error
var probs []error
if raw, ok := item["cell_styles"]; ok {
payload.CellStyles, err = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
if err != nil {
return nil, err
}
var errsHere []error
payload.CellStyles, errsHere = parseWorkbookCreateCellStyleOps(raw, path+".cell_styles")
probs = append(probs, errsHere...)
}
if raw, ok := item["row_sizes"]; ok {
payload.RowSizes, err = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
if err != nil {
return nil, err
}
var errsHere []error
payload.RowSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".row_sizes", "row")
probs = append(probs, errsHere...)
}
if raw, ok := item["col_sizes"]; ok {
payload.ColSizes, err = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
if err != nil {
return nil, err
}
var errsHere []error
payload.ColSizes, errsHere = parseWorkbookCreateResizeOps(raw, path+".col_sizes", "column")
probs = append(probs, errsHere...)
}
if raw, ok := item["cell_merges"]; ok {
payload.CellMerges, err = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
if err != nil {
return nil, err
}
var errsHere []error
payload.CellMerges, errsHere = parseWorkbookCreateMergeOps(raw, path+".cell_merges")
probs = append(probs, errsHere...)
}
if len(probs) > 0 {
return nil, probs
}
if len(payload.CellStyles) == 0 && len(payload.RowSizes) == 0 && len(payload.ColSizes) == 0 && len(payload.CellMerges) == 0 {
return nil, common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)
return nil, []error{common.ValidationErrorf("%s must include at least one of cell_styles/row_sizes/col_sizes/cell_merges", path)}
}
return payload, nil
}
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, error) {
// joinStyleValidationErrors folds the issues collected across one --styles
// parse into a single typed error that lists them all, so the caller can fix
// the whole payload in one retry instead of one error per round trip.
func joinStyleValidationErrors(probs []error) error {
switch len(probs) {
case 0:
return nil
case 1:
return probs[0]
}
const maxShown = 8
msgs := make([]string, 0, len(probs))
for _, e := range probs {
if p, ok := errs.ProblemOf(e); ok {
msgs = append(msgs, p.Message)
continue
}
msgs = append(msgs, e.Error())
}
suffix := ""
if len(msgs) > maxShown {
suffix = fmt.Sprintf(" (+%d more)", len(msgs)-maxShown)
msgs = msgs[:maxShown]
}
return common.ValidationErrorf("--styles has %d issues: %s%s", len(probs), strings.Join(msgs, " | "), suffix)
}
func parseWorkbookCreateCellStyleOps(v interface{}, path string) ([]workbookCreateCellStyleOp, []error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
}
ops := make([]workbookCreateCellStyleOp, 0, len(arr))
var probs []error
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
op, err := parseWorkbookCreateCellStyleOp(raw, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
probs = append(probs, err)
continue
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
}
styleObj := make(map[string]interface{}, len(op)-1)
for k, v := range op {
if k == "range" {
continue
}
styleObj[k] = v
}
style, err := normalizeWorkbookCreateStyleObject(styleObj, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
}
if len(style) == 0 {
return nil, common.ValidationErrorf("%s[%d] must include at least one style field", path, i)
}
ops = append(ops, workbookCreateCellStyleOp{Range: rangeStr, Style: style})
ops = append(ops, op)
}
return ops, nil
return ops, probs
}
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, error) {
func parseWorkbookCreateCellStyleOp(raw interface{}, path string) (workbookCreateCellStyleOp, error) {
op, ok := raw.(map[string]interface{})
if !ok {
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must be an object", path)
}
rangeStr, err := requireWorkbookCreateRange(op, path)
if err != nil {
return workbookCreateCellStyleOp{}, err
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
}
styleObj := make(map[string]interface{}, len(op)-1)
for k, v := range op {
if k == "range" {
continue
}
styleObj[k] = v
}
style, err := normalizeWorkbookCreateStyleObject(styleObj, path)
if err != nil {
return workbookCreateCellStyleOp{}, err
}
if len(style) == 0 {
return workbookCreateCellStyleOp{}, common.ValidationErrorf("%s must include at least one style field", path)
}
return workbookCreateCellStyleOp{Range: rangeStr, Style: style}, nil
}
func parseWorkbookCreateMergeOps(v interface{}, path string) ([]workbookCreateMergeOp, []error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
}
ops := make([]workbookCreateMergeOp, 0, len(arr))
var probs []error
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
op, err := parseWorkbookCreateMergeOp(raw, fmt.Sprintf("%s[%d]", path, i))
if err != nil {
return nil, err
probs = append(probs, err)
continue
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return nil, common.ValidationErrorf("%s[%d].range %q: %v", path, i, rangeStr, err)
}
mergeType := "all"
if raw, ok := op["merge_type"]; ok {
v, ok := raw.(string)
if !ok || strings.TrimSpace(v) == "" {
return nil, common.ValidationErrorf("%s[%d].merge_type must be a non-empty string", path, i)
}
mergeType = strings.TrimSpace(v)
}
switch mergeType {
case "all", "rows", "columns":
default:
return nil, common.ValidationErrorf("%s[%d].merge_type %q is invalid (want all/rows/columns)", path, i, mergeType)
}
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "merge_type"); err != nil {
return nil, err
}
ops = append(ops, workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType})
ops = append(ops, op)
}
return ops, nil
return ops, probs
}
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, error) {
func parseWorkbookCreateMergeOp(raw interface{}, path string) (workbookCreateMergeOp, error) {
op, ok := raw.(map[string]interface{})
if !ok {
return workbookCreateMergeOp{}, common.ValidationErrorf("%s must be an object", path)
}
rangeStr, err := requireWorkbookCreateRange(op, path)
if err != nil {
return workbookCreateMergeOp{}, err
}
if _, _, _, _, err := workbookCreateStyleRangeBounds(rangeStr); err != nil {
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.range %q: %v", path, rangeStr, err)
}
mergeType := "all"
if raw, ok := op["merge_type"]; ok {
v, ok := raw.(string)
if !ok || strings.TrimSpace(v) == "" {
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type must be a non-empty string", path)
}
mergeType = normalizeMergeType(strings.TrimSpace(v))
}
switch mergeType {
case "all", "rows", "columns":
default:
return workbookCreateMergeOp{}, common.ValidationErrorf("%s.merge_type %q is invalid (want all/rows/columns)", path, mergeType)
}
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "merge_type"); err != nil {
return workbookCreateMergeOp{}, err
}
return workbookCreateMergeOp{Range: rangeStr, MergeType: mergeType}, nil
}
// normalizeMergeType maps the raw OpenAPI merge vocabulary (MERGE_ALL /
// MERGE_ROWS / MERGE_COLUMNS — which agents reproduce from the Lark API
// docs) onto the CLI's all/rows/columns. Unknown values pass through for
// the caller's enum check to reject.
func normalizeMergeType(v string) string {
lower := strings.ToLower(v)
lower = strings.TrimPrefix(lower, "merge_")
switch lower {
case "all", "rows", "columns":
return lower
}
return v
}
func parseWorkbookCreateResizeOps(v interface{}, path, dimension string) ([]workbookCreateResizeOp, []error) {
arr, ok := v.([]interface{})
if !ok {
return nil, common.ValidationErrorf("%s must be an array", path)
return nil, []error{common.ValidationErrorf("%s must be an array", path)}
}
ops := make([]workbookCreateResizeOp, 0, len(arr))
var probs []error
for i, raw := range arr {
op, ok := raw.(map[string]interface{})
if !ok {
return nil, common.ValidationErrorf("%s[%d] must be an object", path, i)
}
rangeStr, err := requireWorkbookCreateRange(op, fmt.Sprintf("%s[%d]", path, i))
op, err := parseWorkbookCreateResizeOp(raw, fmt.Sprintf("%s[%d]", path, i), dimension)
if err != nil {
return nil, err
probs = append(probs, err)
continue
}
parsedDim, _, _, err := parseA1Range(rangeStr)
if err != nil {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return nil, common.ValidationErrorf("%s[%d].range %q must use %s: %v", path, i, rangeStr, want, err)
}
if parsedDim != dimension {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return nil, common.ValidationErrorf("%s[%d].range %q must use %s", path, i, rangeStr, want)
}
typeHint := "pixel/standard"
if dimension == "row" {
typeHint = "pixel/standard/auto"
}
resizeType, _ := op["type"].(string)
resizeType = strings.TrimSpace(resizeType)
if resizeType == "" {
return nil, common.ValidationErrorf("%s[%d].type is required (%s)", path, i, typeHint)
}
if dimension == "column" && resizeType == "auto" {
return nil, common.ValidationErrorf("%s[%d].type auto is rows-only", path, i)
}
switch resizeType {
case "pixel", "standard", "auto":
default:
return nil, common.ValidationErrorf("%s[%d].type %q is invalid (want %s)", path, i, resizeType, typeHint)
}
size := 0
if raw, ok := op["size"]; ok {
n, ok := util.ToFloat64(raw)
if !ok || n <= 0 {
return nil, common.ValidationErrorf("%s[%d].size must be a positive number", path, i)
}
size = int(n)
}
if resizeType == "pixel" && size <= 0 {
return nil, common.ValidationErrorf("%s[%d].type pixel requires size", path, i)
}
if resizeType != "pixel" && size > 0 {
return nil, common.ValidationErrorf("%s[%d].size is only valid with type pixel", path, i)
}
if err := rejectUnexpectedWorkbookStyleFields(op, fmt.Sprintf("%s[%d]", path, i), "range", "type", "size"); err != nil {
return nil, err
}
ops = append(ops, workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size})
ops = append(ops, op)
}
return ops, nil
return ops, probs
}
// resizeOpExample renders a complete valid op for the dimension, inlined on
// every type/size error: eval traces show the field errors chaining (type
// "custom" → fixed to pixel → "pixel requires size"), each costing a round
// trip, because no error ever showed a whole valid op at once.
func resizeOpExample(dimension string) string {
if dimension == "column" {
return `{"range":"A:C","type":"pixel","size":120} (or {"range":"A:C","type":"standard"} to reset)`
}
return `{"range":"2:10","type":"pixel","size":32} (or "type":"auto" to fit content)`
}
func parseWorkbookCreateResizeOp(raw interface{}, path, dimension string) (workbookCreateResizeOp, error) {
op, ok := raw.(map[string]interface{})
if !ok {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s must be an object", path)
}
rangeStr, err := requireWorkbookCreateRange(op, path)
if err != nil {
return workbookCreateResizeOp{}, err
}
parsedDim, _, _, err := parseA1Range(rangeStr)
if err != nil {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s: %v", path, rangeStr, want, err)
}
if parsedDim != dimension {
want := "row numbers like 2:10"
if dimension == "column" {
want = "column letters like A:E"
}
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.range %q must use %s", path, rangeStr, want)
}
typeHint := "pixel/standard"
if dimension == "row" {
typeHint = "pixel/standard/auto"
}
resizeType, _ := op["type"].(string)
resizeType = strings.TrimSpace(resizeType)
if resizeType == "" {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type is required (%s), e.g. %s", path, typeHint, resizeOpExample(dimension))
}
if dimension == "column" && resizeType == "auto" {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type auto is rows-only", path)
}
switch resizeType {
case "pixel", "standard", "auto":
default:
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type %q is invalid (want %s), e.g. %s", path, resizeType, typeHint, resizeOpExample(dimension))
}
size := 0
if raw, ok := op["size"]; ok {
n, ok := util.ToFloat64(raw)
if !ok || n <= 0 {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size must be a positive number", path)
}
size = int(n)
}
if resizeType == "pixel" && size <= 0 {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.type pixel requires size, e.g. %s", path, resizeOpExample(dimension))
}
if resizeType != "pixel" && size > 0 {
return workbookCreateResizeOp{}, common.ValidationErrorf("%s.size is only valid with type pixel", path)
}
if err := rejectUnexpectedWorkbookStyleFields(op, path, "range", "type", "size"); err != nil {
return workbookCreateResizeOp{}, err
}
return workbookCreateResizeOp{Range: normalizeWorkbookResizeRange(rangeStr), ResizeType: resizeType, Size: size}, nil
}
func requireWorkbookCreateRange(op map[string]interface{}, path string) (string, error) {
@@ -1259,6 +1360,7 @@ func normalizeWorkbookCreateStyleObject(in map[string]interface{}, path string)
if !ok {
return nil, common.ValidationErrorf("%s.border_styles must be a JSON object", path)
}
expandBorderAllShorthand(m)
if err := validateWorkbookBorderStyles(m, path); err != nil {
return nil, err
}
@@ -1299,7 +1401,7 @@ func validateWorkbookBorderStyles(m map[string]interface{}, path string) error {
switch side {
case "top", "bottom", "left", "right":
default:
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right)", path, side)
return common.ValidationErrorf("%s.border_styles.%s is not a valid side (want top/bottom/left/right; a horizontal line is the top/bottom side of its range, a vertical line is left/right)", path, side)
}
spec, ok := raw.(map[string]interface{})
if !ok {

View File

@@ -48,7 +48,11 @@ var CellsSet = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-set"),
Validate: validateViaInput(cellsSetInput),
Tips: []string{
`Example: lark-cli sheets +cells-set --url <URL> --sheet-name Sheet1 --range A1:B1 --cells '[[{"value":"名称"},{"formula":"=SUM(B2:B9)"}]]'`,
`--cells is always a 2D array (rows × cells), even for one cell: [[{"value":…}]].`,
},
Validate: validateViaInput(cellsSetInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)
@@ -124,7 +128,11 @@ var CellsSetStyle = common.Shortcut{
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: flagsFor("+cells-set-style"),
Validate: validateViaInput(cellsSetStyleInput),
Tips: []string{
`Example: lark-cli sheets +cells-set-style --url <URL> --sheet-name Sheet1 --range A1:D1 --font-weight bold --background-color "#F0F0F0" --horizontal-alignment center`,
`Borders take JSON: --border-styles '{"top":{"style":"solid","weight":"thin","color":"#000000"}}' (sides: top/bottom/left/right).`,
},
Validate: validateViaInput(cellsSetStyleInput),
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := resolveSpreadsheetToken(runtime)
sheetID, sheetName, _ := resolveSheetSelector(runtime)

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"encoding/json"
"strings"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/shortcuts/common"
)
// ─── lark_sheet read → file offload ───────────────────────────────────
//
// Shared plumbing for +cells-get / +csv-get / +table-get behind the
// --output-path flag: when a caller redirects a read to a file, the char cap
// should default to unlimited so the whole sheet lands on disk instead of being
// clipped by the stdout-oriented max_chars safety cap.
// readOutputPath returns the trimmed --output-path flag value ("" when unset).
func readOutputPath(runtime *common.RuntimeContext) string {
return strings.TrimSpace(runtime.Str("output-path"))
}
// maxCharsInput resolves the max_chars value to send to the underlying read
// tool. With --output-path set the cap is lifted (unbounded sentinel) so the
// full result is written to the file; otherwise the --max-chars value binds.
// The second return is false when nothing should be sent (max-chars <= 0), in
// which case the tool's own default applies. Note the tool truncates at ~50000
// even when max_chars is omitted, so callers that want an explicit cap should
// pass a positive default.
func maxCharsInput(runtime *common.RuntimeContext) (int, bool) {
if readOutputPath(runtime) != "" {
return unboundedReadLimit, true
}
if n := runtime.Int("max-chars"); n > 0 {
return n, true
}
return 0, false
}
// emitReadResult delivers a read shortcut's result. When --output-path is set it
// writes the data payload to that path as pretty JSON and prints a small
// confirmation envelope to stdout (path + byte count); otherwise it prints the
// full result envelope to stdout as usual.
func emitReadResult(runtime *common.RuntimeContext, out interface{}) error {
path := readOutputPath(runtime)
if path == "" {
runtime.Out(out, nil)
return nil
}
b, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
if _, err := runtime.FileIO().Save(path, fileio.SaveOptions{}, bytes.NewReader(b)); err != nil {
return err
}
resolved, err := runtime.FileIO().ResolvePath(path)
if err != nil {
resolved = path
}
runtime.Out(map[string]interface{}{
"output_path": resolved,
"bytes_written": len(b),
}, nil)
return nil
}

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/util"
@@ -83,7 +84,7 @@ func callTool(
code, _ := util.ToFloat64(envelope["code"])
if code != 0 {
msg, _ := envelope["msg"].(string)
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), msg).
return nil, errs.NewAPIError(errs.SubtypeServerError, "tool %q failed: [%d] %s", toolName, int(code), flattenToolErrorMsg(msg)).
WithCode(int(code))
}
data, _ := envelope["data"].(map[string]interface{})
@@ -100,6 +101,47 @@ func callTool(
return out, nil
}
// flattenToolErrorMsg unwraps the nested-escaped-JSON error payload some
// sheet-ai tools put in msg — batch_update in particular wraps its result as
// {"error":"{\"message\":\"batch_update: N succeeded, M failed\",
// \"failures\":[…]}","errorType":…,"data":{…}} — into one readable line
// naming each failed operation. Eval traces show agents (and even the eval
// aggregator) failing to extract the real cause from the double-escaped
// form. Anything that doesn't match the nested shape passes through
// untouched.
func flattenToolErrorMsg(msg string) string {
trimmed := strings.TrimSpace(msg)
if !strings.HasPrefix(trimmed, "{") {
return msg
}
var outer struct {
Error string `json:"error"`
}
if json.Unmarshal([]byte(trimmed), &outer) != nil || strings.TrimSpace(outer.Error) == "" {
return msg
}
inner := strings.TrimSpace(outer.Error)
var detail struct {
Message string `json:"message"`
Failures []struct {
Index int `json:"index"`
ToolName string `json:"tool_name"`
Error string `json:"error"`
} `json:"failures"`
}
if strings.HasPrefix(inner, "{") && json.Unmarshal([]byte(inner), &detail) == nil && detail.Message != "" {
if len(detail.Failures) == 0 {
return detail.Message
}
parts := make([]string, 0, len(detail.Failures))
for _, f := range detail.Failures {
parts = append(parts, fmt.Sprintf("operations[%d] (%s): %s", f.Index, f.ToolName, f.Error))
}
return detail.Message + " — " + strings.Join(parts, "; ")
}
return inner
}
// invokeToolDryRun renders the One-OpenAPI request the shortcut would send.
// The wire-format body (with input serialized to a JSON string) is preserved
// for fidelity, and a decoded tool_input map is surfaced alongside so humans

View File

@@ -0,0 +1,57 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// TestFlattenToolErrorMsg pins the unwrap of batch_update's double-escaped
// error payload (the exact shape from eval V2U038/V2U013 traces) and the
// pass-through of everything else.
func TestFlattenToolErrorMsg(t *testing.T) {
t.Parallel()
t.Run("batch failures flatten to one line", func(t *testing.T) {
t.Parallel()
msg := `{"error":"{\"message\":\"batch_update: 0 succeeded, 1 failed\",\"succeeded\":0,\"failed\":1,\"failures\":[{\"index\":0,\"tool_name\":\"manage_chart_object\",\"error\":\"invalid snapshot.data.dim1.serie.index: 0, must be >= 1 (index is 1-based)\",\"errorType\":\"param_error\"}]}","errorType":"param_error","data":{"total":2,"succeeded":0,"failed":1}}`
got := flattenToolErrorMsg(msg)
for _, want := range []string{
"batch_update: 0 succeeded, 1 failed",
"operations[0] (manage_chart_object): invalid snapshot.data.dim1.serie.index",
} {
if !strings.Contains(got, want) {
t.Errorf("flattened msg should contain %q, got %q", want, got)
}
}
if strings.Contains(got, `\"`) {
t.Errorf("flattened msg must not carry escaped JSON, got %q", got)
}
})
t.Run("plain-string inner error unwraps", func(t *testing.T) {
t.Parallel()
got := flattenToolErrorMsg(`{"error":"sheet \"s\" not found","errorType":"param_error"}`)
if got != `sheet "s" not found` {
t.Errorf("got %q", got)
}
})
t.Run("non-JSON msg passes through", func(t *testing.T) {
t.Parallel()
msg := `cell at row 0, col 1 is inside a merged region (top-left: A1)`
if got := flattenToolErrorMsg(msg); got != msg {
t.Errorf("got %q", got)
}
})
t.Run("JSON without error field passes through", func(t *testing.T) {
t.Parallel()
msg := `{"detail":"x"}`
if got := flattenToolErrorMsg(msg); got != msg {
t.Errorf("got %q", got)
}
})
}

View File

@@ -35,6 +35,11 @@ func Shortcuts() []common.Shortcut {
if hasFlag(all[i].Flags, "spreadsheet-token") {
all[i].PostMount = withTokenAlias(all[i].PostMount)
}
// +chart-create grows --print-example (minimal per-type --properties
// templates) — the biggest --print-schema consumer in eval traces.
if all[i].Command == "+chart-create" {
all[i].PostMount = withChartPrintExample(all[i].PostMount)
}
// Sheets-scoped flag ergonomics (unknown-flag hints with the valid
// flags inlined, enum vocabulary normalization) ride the same
// PostMount composition, so no other domain's behavior shifts.
@@ -146,6 +151,7 @@ func shortcutList() []common.Shortcut {
// Object CRUD (3 per skill)
ChartCreate, ChartUpdate, ChartDelete,
ChartCreateBasic, ChartConfigUpdate, ChartDataUpdate,
PivotCreate, PivotUpdate, PivotDelete,
CondFormatCreate, CondFormatUpdate, CondFormatDelete,
FilterCreate, FilterUpdate, FilterDelete,

View File

@@ -0,0 +1,250 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"strings"
"testing"
)
// TestTablePut_StylesErrorsAggregate pins the one-retry contract for
// --styles: every issue across sections and ops is reported in a single
// error (eval V2U032 burned three round trips fixing a border side, then
// row_sizes.type, then size — each surfaced only after the previous fix).
func TestTablePut_StylesErrorsAggregate(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+table-put")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"s","columns":["a"],"data":[["x"]]}]}`,
"--styles", `{"styles":[{"name":"s",
"cell_styles":[{"range":"A1:A1","border_styles":{"horizontal":{"style":"solid"}}}],
"row_sizes":[{"range":"1:1","type":"custom"}],
"col_sizes":[{"range":"A:A","type":"pixel"}]}]}`,
"--dry-run",
})
ve := requireValidation(t, err, "--styles has 3 issues")
for _, want := range []string{
"border_styles.horizontal is not a valid side",
`row_sizes[0].type "custom" is invalid`,
"col_sizes[0].type pixel requires size",
} {
if !strings.Contains(ve.Message, want) {
t.Errorf("aggregated message should contain %q, got %q", want, ve.Message)
}
}
// D2: each type/size error inlines a complete valid op.
if !strings.Contains(ve.Message, `{"range":"2:10","type":"pixel","size":32}`) {
t.Errorf("row_sizes error should inline a full valid example, got %q", ve.Message)
}
if !strings.Contains(ve.Message, `{"range":"A:C","type":"pixel","size":120}`) {
t.Errorf("col_sizes error should inline a full valid example, got %q", ve.Message)
}
}
// TestTablePut_StylesBorderAllExpands verifies the "all" shorthand is
// rewritten to four explicit sides instead of being rejected (or worse,
// passed through for the server to reject, as happened on the typed-cells
// path in eval V2U013/V2U021).
func TestTablePut_StylesBorderAllExpands(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+table-put")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheets", `{"sheets":[{"name":"s","columns":["a"],"data":[["x"]]}]}`,
"--styles", `{"styles":[{"name":"s","cell_styles":[{"range":"A1:A1","border_styles":{"all":{"style":"solid","weight":"thin"}}}]}]}`,
"--dry-run",
})
if err != nil {
t.Fatalf("border all should expand to four sides and pass, got: %v", err)
}
// table-put's dry-run body carries the tool input as an escaped JSON
// string, so match the escaped key form.
for _, side := range []string{`\"top\"`, `\"bottom\"`, `\"left\"`, `\"right\"`} {
if !strings.Contains(stdout, side) {
t.Errorf("dry-run body should carry expanded side %s, got %q", side, stdout)
}
}
if strings.Contains(stdout, `\"all\"`) {
t.Errorf("dry-run body must not carry the raw all shorthand, got %q", stdout)
}
}
// TestCellsSet_BorderAllAndMisNestedBorder covers the typed --cells path:
// the "all" shorthand expands CLI-side, and border_styles mis-nested inside
// cell_styles is intercepted with a move-it prescription instead of a
// server-side 900015206.
func TestCellsSet_BorderAllAndMisNestedBorder(t *testing.T) {
t.Parallel()
t.Run("border all expands", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1",
"--cells", `[[{"value":"x","border_styles":{"all":{"style":"solid"}}}]]`,
"--dry-run",
})
if err != nil {
t.Fatalf("border all should expand and pass, got: %v", err)
}
if strings.Contains(stdout, `"all"`) || !strings.Contains(stdout, `"top"`) {
t.Errorf("dry-run body should carry expanded sides, got %q", stdout)
}
})
t.Run("mis-nested border_styles intercepted", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1",
"--cells", `[[{"value":"x","cell_styles":{"font_weight":"bold","border_styles":{"top":{"style":"solid"}}}}]]`,
"--dry-run",
})
ve := requireValidation(t, err, "cell_styles.border_styles is not valid")
if !strings.Contains(ve.Message, "sibling of cell_styles") {
t.Errorf("message should prescribe moving it up one level, got %q", ve.Message)
}
})
}
// TestCellsSetStyle_BorderAllExpands covers the --border-styles flag path
// (+cells-set-style / +cells-batch-set-style go through borderStylesFromFlag,
// not the typed --cells or --styles walkers): the "all" shorthand must expand
// CLI-side here too, or the backend rejects {"all":…}.
func TestCellsSetStyle_BorderAllExpands(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set-style")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1:A1",
"--border-styles", `{"all":{"style":"solid","weight":"thin"}}`,
"--dry-run",
})
if err != nil {
t.Fatalf("border all should expand to four sides and pass, got: %v", err)
}
for _, side := range []string{`"top"`, `"bottom"`, `"left"`, `"right"`} {
if !strings.Contains(stdout, side) {
t.Errorf("dry-run body should carry expanded side %s, got %q", side, stdout)
}
}
if strings.Contains(stdout, `"all"`) {
t.Errorf("dry-run body must not carry the raw all shorthand, got %q", stdout)
}
}
// TestCellsMerge_RawAPIVocabularyNormalizes pins MERGE_ALL → all (the raw
// OpenAPI enum agents copy from Lark API docs) via the enum alias table.
func TestCellsMerge_RawAPIVocabularyNormalizes(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-merge")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1:B2",
"--merge-type", "MERGE_ALL",
"--dry-run",
})
if err != nil {
t.Fatalf("MERGE_ALL should normalize to all and pass, got: %v", err)
}
if !strings.Contains(stdout, `"all"`) {
t.Errorf("dry-run body should carry the normalized merge type, got %q", stdout)
}
}
// TestCellsSetStyle_WordWrapBooleanNormalizes pins --word-wrap true →
// auto-wrap (eval V2U029).
func TestCellsSetStyle_WordWrapBooleanNormalizes(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set-style")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1:A1",
"--word-wrap", "true",
"--dry-run",
})
if err != nil {
t.Fatalf("--word-wrap true should normalize to auto-wrap, got: %v", err)
}
if !strings.Contains(stdout, "auto-wrap") {
t.Errorf("dry-run body should carry auto-wrap, got %q", stdout)
}
}
// TestUnderscoreFlagFormsParse pins the wire-vocabulary underscore rewrite:
// --sheet_name / --border_styles parse as their hyphen forms (agents copy
// field names out of JSON payloads where underscores are canonical).
func TestUnderscoreFlagFormsParse(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set-style")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet_name", "s",
"--range", "A1:A1",
"--font_weight", "bold",
"--dry-run",
})
if err != nil {
t.Fatalf("underscore flag forms should parse as hyphen forms, got: %v", err)
}
if !strings.Contains(stdout, "bold") {
t.Errorf("dry-run body should carry the style, got %q", stdout)
}
}
// TestPrintFlagSchema_UnderscoreFlagName pins --flag-name border_styles
// resolving the border-styles schema (eval V2U013 burned a retry on this).
func TestPrintFlagSchema_UnderscoreFlagName(t *testing.T) {
t.Parallel()
print := printFlagSchemaFor("+cells-set-style")
out, err := print("border_styles")
if err != nil {
t.Fatalf("underscore flag-name should resolve the hyphen schema, got: %v", err)
}
if len(out) == 0 {
t.Fatal("expected schema output")
}
}
// TestPrintFlagSchema_DottedPathSlices pins the schema sub-path slicing
// contract on the real embedded chart schema: a dotted --flag-name returns
// just that subtree, and a path miss lists the keys actually available.
func TestPrintFlagSchema_DottedPathSlices(t *testing.T) {
t.Parallel()
print := printFlagSchemaFor("+chart-create")
t.Run("slices a nested subtree", func(t *testing.T) {
t.Parallel()
out, err := print("properties.snapshot.plotArea.axes")
if err != nil {
t.Fatalf("dotted path should slice, got: %v", err)
}
full, err2 := print("properties")
if err2 != nil {
t.Fatalf("full dump: %v", err2)
}
if len(out) == 0 || len(out) >= len(full) {
t.Errorf("slice should be non-empty and smaller than the full schema (%d vs %d bytes)", len(out), len(full))
}
})
t.Run("path miss lists available keys", func(t *testing.T) {
t.Parallel()
_, err := print("properties.snapshot.nosuchkey")
if err == nil {
t.Fatal("expected error for unknown path segment")
}
if !strings.Contains(err.Error(), "available keys:") {
t.Errorf("error should list available keys, got %v", err)
}
})
}

50
shortcuts/vc/helpers.go Normal file
View File

@@ -0,0 +1,50 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"errors"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/common"
)
const (
meetingQueryUserScope = "vc:meeting.meetingevent:read"
meetingQueryBotScope = "vc:meeting.bot.join:write"
)
func normalizeMeetingQueryPermissionError(runtime *common.RuntimeContext, err error) error {
if runtime == nil {
return err
}
var permissionErr *errs.PermissionError
if !errors.As(err, &permissionErr) || permissionErr == nil {
return err
}
switch {
case runtime.As() == core.AsUser && permissionErr.Code == output.LarkErrUserScopeInsufficient:
permissionErr.Message = "access denied for user identity; recommended scope: " + meetingQueryUserScope
permissionErr.WithHint("for user identity, run `lark-cli auth login --scope %q` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", meetingQueryUserScope)
permissionErr.WithMissingScopes(meetingQueryUserScope)
return err
case runtime.As() == core.AsBot && permissionErr.Code == output.LarkErrAppScopeNotEnabled:
permissionErr.Message = "access denied for bot identity; recommended scope: " + meetingQueryBotScope
permissionErr.WithHint("ask the app developer to enable scope %s", meetingQueryBotScope)
permissionErr.WithMissingScopes(meetingQueryBotScope)
if runtime.Config != nil {
consoleURL := registry.BuildConsoleScopeURL(runtime.Config.Brand, runtime.Config.AppID, meetingQueryBotScope)
if consoleURL != "" {
permissionErr.WithConsoleURL(consoleURL)
}
}
return err
default:
return err
}
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package vc
import (
"errors"
"net/url"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
func bareMeetingQueryRuntime(as core.Identity) *common.RuntimeContext {
return common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "test"}, defaultConfig(), as)
}
func TestNormalizeMeetingQueryPermissionError_NilRuntimeReturnsOriginalError(t *testing.T) {
original := errs.NewPermissionError(errs.SubtypeMissingScope, "permission failure").
WithCode(output.LarkErrUserScopeInsufficient)
if got := normalizeMeetingQueryPermissionError(nil, original); got != original {
t.Fatalf("got %v, want original error %v", got, original)
}
}
func TestNormalizeMeetingQueryPermissionError_TypedNilReturnsOriginalError(t *testing.T) {
var permissionErr *errs.PermissionError
var original error = permissionErr
if got := normalizeMeetingQueryPermissionError(bareMeetingQueryRuntime(core.AsUser), original); got != original {
t.Fatalf("got %v, want original error %v", got, original)
}
}
func assertMeetingQueryPermissionError(t *testing.T, err error, identity core.Identity, code int) {
t.Helper()
var pe *errs.PermissionError
if !errors.As(err, &pe) {
t.Fatalf("expected *errs.PermissionError, got %T: %v", err, err)
}
if pe.Category != errs.CategoryAuthorization {
t.Fatalf("Category = %q, want %q", pe.Category, errs.CategoryAuthorization)
}
if pe.Subtype != errs.SubtypeMissingScope && pe.Subtype != errs.SubtypeAppScopeNotApplied {
t.Fatalf("Subtype = %q, want a missing-scope subtype", pe.Subtype)
}
if pe.Identity != string(identity) {
t.Fatalf("Identity = %q, want %q", pe.Identity, identity)
}
wantScope := meetingQueryUserScope
if identity.IsBot() {
wantScope = meetingQueryBotScope
}
if !strings.Contains(pe.Hint, wantScope) {
t.Fatalf("Hint = %q, want recommended scope %q", pe.Hint, wantScope)
}
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != wantScope {
t.Fatalf("MissingScopes = %v, want only recommended scope %q", pe.MissingScopes, wantScope)
}
if strings.Contains(pe.Hint, "either compatible scope") {
t.Fatalf("Hint = %q, must not repeat the OR-scope explanation from message", pe.Hint)
}
switch code {
case output.LarkErrAppScopeNotEnabled:
if strings.Contains(pe.Hint, "auth login") {
t.Fatalf("Hint = %q, app-scope error must not recommend user login", pe.Hint)
}
if !strings.Contains(pe.Hint, "app developer") {
t.Fatalf("Hint = %q, want app developer guidance", pe.Hint)
}
if pe.ConsoleURL == "" {
t.Fatal("ConsoleURL is empty, want identity-specific developer-console URL")
}
if strings.Contains(pe.ConsoleURL, url.QueryEscape(meetingQueryUserScope)) || !strings.Contains(pe.ConsoleURL, url.QueryEscape(meetingQueryBotScope)) {
t.Fatalf("ConsoleURL = %q, want only bot scope", pe.ConsoleURL)
}
case output.LarkErrUserScopeInsufficient:
if !strings.Contains(pe.Hint, "auth login --scope") {
t.Fatalf("Hint = %q, want auth login guidance", pe.Hint)
}
if pe.ConsoleURL != "" {
t.Fatalf("ConsoleURL = %q, user-scope error must not expose a developer-console URL", pe.ConsoleURL)
}
default:
t.Fatalf("unexpected code %d", code)
}
}
func TestNormalizeMeetingQueryPermissionError_RecommendsScopeForMatchingIdentity(t *testing.T) {
cases := []struct {
name string
identity core.Identity
code int
subtype errs.Subtype
}{
{name: "user_with_user_scope_error", identity: core.AsUser, code: output.LarkErrUserScopeInsufficient, subtype: errs.SubtypeMissingScope},
{name: "bot_with_app_scope_error", identity: core.AsBot, code: output.LarkErrAppScopeNotEnabled, subtype: errs.SubtypeAppScopeNotApplied},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
wantScope := meetingQueryUserScope
if tc.identity == core.AsBot {
wantScope = meetingQueryBotScope
}
wantMessage := "access denied for " + string(tc.identity) + " identity; recommended scope: " + wantScope
original := errs.NewPermissionError(tc.subtype, "upstream permission failure").
WithCode(tc.code).
WithLogID("log-id").
WithRetryable().
WithIdentity(string(tc.identity)).
WithMissingScopes(meetingQueryUserScope, meetingQueryBotScope).
WithRequestedScopes("requested:scope").
WithGrantedScopes("granted:scope")
if tc.identity == core.AsBot {
original.ConsoleURL = "https://example.com/scopes"
}
original.Troubleshooter = "https://example.com/troubleshoot"
got := normalizeMeetingQueryPermissionError(bareMeetingQueryRuntime(tc.identity), original)
var pe *errs.PermissionError
if !errors.As(got, &pe) {
t.Fatalf("got %T, want *errs.PermissionError", got)
}
if got != original || pe != original {
t.Fatal("normalizer did not return the original permission error")
}
if pe.Code != tc.code || pe.Subtype != tc.subtype || pe.LogID != "log-id" || !pe.Retryable {
t.Fatalf("diagnostics changed: %+v", pe.Problem)
}
if pe.Troubleshooter != original.Troubleshooter {
t.Fatalf("Troubleshooter = %q, want %q", pe.Troubleshooter, original.Troubleshooter)
}
if pe.Message != wantMessage {
t.Fatalf("Message = %q, want %q", pe.Message, wantMessage)
}
if tc.identity == core.AsBot {
consoleURL, err := url.Parse(pe.ConsoleURL)
if err != nil {
t.Fatalf("ConsoleURL = %q is invalid: %v", pe.ConsoleURL, err)
}
if consoleURL.Host == "" || consoleURL.Query().Get("clientID") != "test-app" || consoleURL.Query().Get("scopes") != meetingQueryBotScope {
t.Fatalf("ConsoleURL = %q, want test-app and only bot scope", pe.ConsoleURL)
}
} else if pe.ConsoleURL != "" {
t.Fatalf("ConsoleURL = %q, user-scope error must not expose a developer-console URL", pe.ConsoleURL)
}
assertMeetingQueryPermissionError(t, got, tc.identity, tc.code)
})
}
}
func TestNormalizeMeetingQueryPermissionError_PassesThroughNonMatchingErrors(t *testing.T) {
cases := []struct {
name string
identity core.Identity
err error
}{
{
name: "user_with_app_scope_error",
identity: core.AsUser,
err: errs.NewPermissionError(errs.SubtypeAppScopeNotApplied, "app scope error").
WithCode(output.LarkErrAppScopeNotEnabled),
},
{
name: "bot_with_user_scope_error",
identity: core.AsBot,
err: errs.NewPermissionError(errs.SubtypeMissingScope, "user scope error").
WithCode(output.LarkErrUserScopeInsufficient),
},
{
name: "auto_with_user_scope_error",
identity: core.AsAuto,
err: errs.NewPermissionError(errs.SubtypeMissingScope, "auto identity").
WithCode(output.LarkErrUserScopeInsufficient),
},
{
name: "bot_not_in_meeting",
err: errs.NewPermissionError(errs.SubtypePermissionDenied, "not in meeting").WithCode(10005),
},
{
name: "not_in_gray",
err: errs.NewPermissionError(errs.SubtypePermissionDenied, "not in gray").
WithCode(20017),
},
{name: "plain_error", err: errors.New("boom")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
identity := tc.identity
if identity == "" {
identity = core.AsBot
}
if got := normalizeMeetingQueryPermissionError(bareMeetingQueryRuntime(identity), tc.err); got != tc.err {
t.Fatalf("got %T %v, want original error %T %v", got, got, tc.err, tc.err)
}
})
}
}

View File

@@ -79,27 +79,42 @@ func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, mee
result.NoteID = v
}
// Step 2: query minute_token via recording API
minuteToken, minuteHint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
if minuteErr != nil {
// Recording API failed — surface the error but keep data from step 1
result.Error = fmt.Sprintf("failed to query minutes: %v", minuteErr)
minuteHint = ""
}
if minuteToken != "" {
result.MinuteToken = minuteToken
// Step 2: query minute_token via recording API — only meaningful once the
// meeting has ended. While it is still in progress the note/minute are not
// generated yet, so skip the recording call and surface an informational
// hint instead of letting an unclassified recording error fail the command.
inProgress := meetingInProgress(meeting)
var minuteHint string
if inProgress {
minuteHint = "meeting is still in progress; note and minute are not generated yet"
} else {
minuteToken, hint, minuteErr := fetchMeetingMinuteToken(runtime, meetingID)
minuteHint = hint
if minuteErr != nil {
// Recording lookup is a best-effort supplement; step 1 already
// succeeded, so degrade the failure to a hint rather than failing
// the whole command.
minuteHint = fmt.Sprintf("failed to query minutes: %v", minuteErr)
}
if minuteToken != "" {
result.MinuteToken = minuteToken
}
}
// Add hints for empty resources (not errors, just informational)
var emptyFields []string
if result.NoteID == "" {
emptyFields = append(emptyFields, "note_id")
}
if result.MinuteToken == "" && minuteErr == nil && minuteHint == "" {
emptyFields = append(emptyFields, "minute_token")
}
if len(emptyFields) > 0 {
result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
// Add hints for empty resources (not errors, just informational). For an
// in-progress meeting the "not found" wording is noise, so we only emit the
// single in-progress hint below.
if !inProgress {
var emptyFields []string
if result.NoteID == "" {
emptyFields = append(emptyFields, "note_id")
}
if result.MinuteToken == "" && minuteHint == "" {
emptyFields = append(emptyFields, "minute_token")
}
if len(emptyFields) > 0 {
result.Hint = fmt.Sprintf("%s not found for this meeting", strings.Join(emptyFields, ", "))
}
}
if minuteHint != "" {
if result.Hint != "" {
@@ -112,6 +127,36 @@ func fetchMeetingDetail(ctx context.Context, runtime *common.RuntimeContext, mee
return result
}
// meetingTimeField reads a meeting time field as a string regardless of whether
// the API returned it as a JSON string or number. VC serializes int64
// timestamps as strings, but coercing via %v keeps parsing robust either way;
// float64(0) renders as "0", which parseFlexibleTime treats as "absent".
func meetingTimeField(meeting map[string]any, key string) string {
v, ok := meeting[key]
if !ok || v == nil {
return ""
}
return strings.TrimSpace(fmt.Sprintf("%v", v))
}
// meetingInProgress reports whether a meeting is still ongoing, using the same
// start/end heuristic as +meeting-events (meetingEventsMeetingFromPayload): a
// meeting is ongoing when it has a start time but no end time, or its end time
// is not after its start time. It reads the RAW timestamp fields, not the
// FormatTime-rendered result strings, because parseFlexibleTime only accepts
// Unix timestamps or RFC3339. Empty or "0" values are treated as absent.
func meetingInProgress(meeting map[string]any) bool {
start, hasStart := parseFlexibleTime(meetingTimeField(meeting, "start_time"))
end, hasEnd := parseFlexibleTime(meetingTimeField(meeting, "end_time"))
if !hasStart {
return false
}
if !hasEnd {
return true
}
return !end.After(start)
}
// VCDetail gets meeting details including note_id and minute_token.
var VCDetail = common.Shortcut{
Service: "vc",

View File

@@ -269,11 +269,58 @@ func TestFetchMeetingDetail_RecordingAPIErrorButNoteOK(t *testing.T) {
if result.MinuteToken != "" {
t.Errorf("minute_token = %q, want empty", result.MinuteToken)
}
if !strings.Contains(result.Error, "failed to query minutes") || !strings.Contains(result.Error, "weird API error") {
t.Errorf("error = %q, want contains 'failed to query minutes' and 'weird API error'", result.Error)
if result.Error != "" {
t.Errorf("error = %q, want empty: a recording lookup failure must not fail the command", result.Error)
}
if strings.Contains(result.Hint, "minute_token") {
t.Errorf("hint = %q, should not mention minute_token when there is an error", result.Hint)
if !strings.Contains(result.Hint, "failed to query minutes") || !strings.Contains(result.Hint, "weird API error") {
t.Errorf("hint = %q, want contains 'failed to query minutes' and 'weird API error'", result.Hint)
}
return nil
}); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// TestFetchMeetingDetail_MeetingInProgress pins the in-progress behavior: when a
// meeting is still ongoing (end_time not after start_time), +detail must not
// call the recording API at all — it returns meeting metadata with an
// informational hint and no error. Deliberately register NO recording stub so
// that any recording call would fail on an unmatched request.
func TestFetchMeetingDetail_MeetingInProgress(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/vc/v1/meetings/m_live",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"meeting": map[string]interface{}{
"id": "m_live",
"topic": "Live Meeting",
"meeting_no": "912052453",
// end_time == start_time signals an ongoing meeting.
"start_time": "1752000000",
"end_time": "1752000000",
}},
},
})
if err := botExec(t, "detail-live", f, func(_ context.Context, rctx *common.RuntimeContext) error {
result := fetchMeetingDetail(context.Background(), rctx, "m_live")
if result.Topic != "Live Meeting" {
t.Errorf("topic = %q, want 'Live Meeting'", result.Topic)
}
if result.Error != "" {
t.Errorf("error = %q, want empty for an in-progress meeting", result.Error)
}
if result.MinuteToken != "" {
t.Errorf("minute_token = %q, want empty for an in-progress meeting", result.MinuteToken)
}
if !strings.Contains(result.Hint, "in progress") {
t.Errorf("hint = %q, want to mention the meeting is in progress", result.Hint)
}
if strings.Contains(result.Hint, "not found for this meeting") {
t.Errorf("hint = %q, should not emit not-found noise for an in-progress meeting", result.Hint)
}
return nil
}); err != nil {

View File

@@ -52,9 +52,13 @@ var VCMeetingEvents = common.Shortcut{
Command: "+meeting-events",
Description: "List meeting events by meeting ID",
Risk: "read",
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
// UAT exposes user-granted scopes, so the framework can preflight the user
// recommendation. TAT has no scope metadata; keep the bot recommendation
// conditional so it is available to diagnostics without a local preflight.
UserScopes: []string{meetingQueryUserScope},
ConditionalBotScopes: []string{meetingQueryBotScope},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "meeting-id", Required: true, Desc: "meeting ID to query"},
{Name: "start", Desc: "time lower bound (ISO 8601, YYYY-MM-DD, or Unix seconds)"},
@@ -101,7 +105,7 @@ var VCMeetingEvents = common.Shortcut{
}
data, events, hasMore, pageToken, err := fetchMeetingEvents(ctx, runtime, startTime, endTime)
if err != nil {
return err
return normalizeMeetingQueryPermissionError(runtime, err)
}
events = compactMeetingEvents(events)
identity, identityWarning := meetingEventsCurrentIdentity(runtime)

View File

@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"errors"
"net/url"
"reflect"
"strings"
"testing"
@@ -17,6 +18,7 @@ import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -418,6 +420,21 @@ func TestMeetingEvents_Validation_PageAllIgnoresInvalidPageSize(t *testing.T) {
}
}
func TestMeetingEvents_UsesUserScopePreflightAndBotScopeHint(t *testing.T) {
if got := VCMeetingEvents.ScopesForIdentity("user"); !reflect.DeepEqual(got, []string{meetingQueryUserScope}) {
t.Fatalf("ScopesForIdentity(user) = %v, want %v", got, []string{meetingQueryUserScope})
}
if got := VCMeetingEvents.ScopesForIdentity("bot"); len(got) != 0 {
t.Fatalf("ScopesForIdentity(bot) = %v, want no bot preflight scopes", got)
}
if got := VCMeetingEvents.DeclaredScopesForIdentity("user"); !reflect.DeepEqual(got, []string{meetingQueryUserScope}) {
t.Fatalf("DeclaredScopesForIdentity(user) = %v, want %v", got, []string{meetingQueryUserScope})
}
if got := VCMeetingEvents.DeclaredScopesForIdentity("bot"); !reflect.DeepEqual(got, []string{meetingQueryBotScope}) {
t.Fatalf("DeclaredScopesForIdentity(bot) = %v, want %v", got, []string{meetingQueryBotScope})
}
}
func TestMeetingEvents_Validation_InvalidPageSizeReturnsFlagError(t *testing.T) {
runtime := newMeetingEventsRuntime()
mustSetMeetingEventsFlag(t, runtime, "meeting-id", "7628568141510692381")
@@ -637,6 +654,63 @@ func TestMeetingEvents_ExecuteJSON(t *testing.T) {
}
}
func TestMeetingEvents_Execute_NormalizesMeetingScopeError(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: vcMeetingEventsAPIPath,
Status: 400,
Body: map[string]interface{}{
"code": output.LarkErrAppScopeNotEnabled,
"msg": "access denied",
"error": map[string]interface{}{
"permission_violations": []interface{}{
map[string]interface{}{"subject": meetingQueryUserScope},
map[string]interface{}{"subject": meetingQueryBotScope},
},
},
},
})
err := mountAndRun(t, VCMeetingEvents, []string{
"+meeting-events",
"--meeting-id", "7628568141510692381",
"--format", "json",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatal("expected permission error")
}
reg.Verify(t)
var permissionErr *errs.PermissionError
if !errors.As(err, &permissionErr) {
t.Fatalf("error = %T %v, want *errs.PermissionError", err, err)
}
if permissionErr.Code != output.LarkErrAppScopeNotEnabled {
t.Fatalf("Code = %d, want %d", permissionErr.Code, output.LarkErrAppScopeNotEnabled)
}
if permissionErr.Identity != "bot" {
t.Fatalf("Identity = %q, want bot", permissionErr.Identity)
}
wantMessage := "access denied for bot identity; recommended scope: " + meetingQueryBotScope
if permissionErr.Message != wantMessage {
t.Fatalf("Message = %q, want %q", permissionErr.Message, wantMessage)
}
if !strings.Contains(permissionErr.Hint, meetingQueryBotScope) {
t.Fatalf("Hint = %q, want bot scope %q", permissionErr.Hint, meetingQueryBotScope)
}
if len(permissionErr.MissingScopes) != 1 || permissionErr.MissingScopes[0] != meetingQueryBotScope {
t.Fatalf("MissingScopes = %v, want only bot scope %q", permissionErr.MissingScopes, meetingQueryBotScope)
}
if permissionErr.ConsoleURL == "" {
t.Fatal("ConsoleURL is empty, want identity-specific developer-console URL")
}
if strings.Contains(permissionErr.ConsoleURL, url.QueryEscape(meetingQueryUserScope)) || !strings.Contains(permissionErr.ConsoleURL, url.QueryEscape(meetingQueryBotScope)) {
t.Fatalf("ConsoleURL = %q, want only bot scope", permissionErr.ConsoleURL)
}
}
func TestMeetingEvents_ExecuteJSON_BotIdentityErrorDoesNotBlockEvents(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(meetingEventsStub([]interface{}{participantJoinedEvent()}, false, ""))

View File

@@ -23,9 +23,13 @@ var VCMeetingListActive = common.Shortcut{
Command: "+meeting-list-active",
Description: "List active meetings for the current identity or target user",
Risk: "read",
Scopes: []string{"vc:meeting.meetingevent:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
// UAT exposes user-granted scopes, so the framework can preflight the user
// recommendation. TAT has no scope metadata; keep the bot recommendation
// conditional so it is available to diagnostics without a local preflight.
UserScopes: []string{meetingQueryUserScope},
ConditionalBotScopes: []string{meetingQueryBotScope},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "user-id", Desc: "target user ID when using bot identity"},
},
@@ -50,7 +54,7 @@ var VCMeetingListActive = common.Shortcut{
}
data, err := runtime.CallAPITyped(http.MethodGet, vcMeetingListActiveAPIPath, params, nil)
if err != nil {
return err
return normalizeMeetingQueryPermissionError(runtime, err)
}
if data == nil {
data = map[string]interface{}{}

View File

@@ -608,9 +608,18 @@ func TestMeetingListActive_DryRun_UserIdentity(t *testing.T) {
}
}
func TestMeetingListActive_ScopeMatchesEventReadPermission(t *testing.T) {
if len(VCMeetingListActive.Scopes) != 1 || VCMeetingListActive.Scopes[0] != "vc:meeting.meetingevent:read" {
t.Fatalf("scopes = %#v, want [vc:meeting.meetingevent:read]", VCMeetingListActive.Scopes)
func TestMeetingListActive_UsesUserScopePreflightAndBotScopeHint(t *testing.T) {
if got := VCMeetingListActive.ScopesForIdentity("user"); len(got) != 1 || got[0] != meetingQueryUserScope {
t.Fatalf("ScopesForIdentity(user) = %v, want [%s]", got, meetingQueryUserScope)
}
if got := VCMeetingListActive.ScopesForIdentity("bot"); len(got) != 0 {
t.Fatalf("ScopesForIdentity(bot) = %v, want no bot preflight scopes", got)
}
if got := VCMeetingListActive.DeclaredScopesForIdentity("user"); len(got) != 1 || got[0] != meetingQueryUserScope {
t.Fatalf("DeclaredScopesForIdentity(user) = %v, want [%s]", got, meetingQueryUserScope)
}
if got := VCMeetingListActive.DeclaredScopesForIdentity("bot"); len(got) != 1 || got[0] != meetingQueryBotScope {
t.Fatalf("DeclaredScopesForIdentity(bot) = %v, want [%s]", got, meetingQueryBotScope)
}
}

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

@@ -69,19 +69,17 @@ lark-cli approval approvals get \
|---|---|---|
| `--data '{...}'` | 是 | 请求体,使用 JSON 传入 |
| `approval_code` | 是 | 审批定义 Code必须先通过 `approvals search` / `approvals get` 确认 |
| `form` | | 表单值,**JSON 数组字符串**,不是普通对象 |
| `form` | | 表单值,**JSON 数组字符串**,不是普通对象API 层非必填,但审批定义存在必填控件或用户需要提交表单值时必须传 |
| `node_approver_list` | 否 | 节点审批人列表;仅在定义要求补充审批人时传 |
| `node_cc_list` | 否 | 节点抄送人列表;仅在用户明确需要补充节点抄送人时传 |
| `uuid` | 否 | 幂等标识;重复重试同一请求时建议显式传入 |
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入 |
| `user_id_type` | 否 | 用户 ID 类型:`user_id``union_id``open_id`;涉及人员类 ID 时建议显式传 `open_id` |
| `--as user` | 否 | 建议显式指定用户身份;审批发起通常应使用用户身份 |
| `--yes` | 是 | 写操作确认;真实执行时必须显式传入 |
| `--dry-run` | 否 | 预览 API 调用,不执行 |
### 4. 组装 `form`
`instances create --data.form` 是一个 JSON 数组字符串。组装原则:
`instances create --data.form`可选字段;传入时必须是一个 JSON 数组字符串。无表单或无需填写表单值的审批可省略 `form`,但只要审批定义包含需要提交的控件,就必须按控件结构组装后传入。组装原则:
- 先用 `approvals.get.form` 识别有哪些控件、每个控件的 `id` / `type` / 可选值范围,再按本文中的创建参数规则与 [`lark-approval-instance-form-control-parameters.md`](./lark-approval-instance-form-control-parameters.md) 重新组装创建 payload。
- 提交时必须至少保证每个控件的 `id``type``value` 符合当前接口要求;不要假设定义快照里出现的其他字段都能直接照搬。
@@ -173,7 +171,6 @@ lark-cli approval instances create \
}
]
}' \
--params '{"user_id_type":"open_id"}' \
--as user \
--yes
```

View File

@@ -14,6 +14,9 @@ lark-cli approval instances initiated --params '{"page_size":20}' --as user
# 只看某个审批定义下我发起的实例
lark-cli approval instances initiated --params '{"definition_code":"<DEFINITION_CODE>","page_size":20}' --as user
# 按发起时间范围筛选(秒级时间戳)
lark-cli approval instances initiated --params '{"start_timestamp":"<START_SECONDS>","end_timestamp":"<END_SECONDS>","page_size":20}' --as user
# 使用 page_token 翻页
lark-cli approval instances initiated --params '{"page_size":20,"page_token":"example_page_token"}' --as user
@@ -30,6 +33,8 @@ lark-cli approval instances initiated --params '{"page_size":20}' --as user --dr
|------|------|------|
| `--params '{...}'` | 否 | 查询参数,使用 JSON 传入;不传时使用默认分页与筛选 |
| `definition_code` | 否 | 审批定义 Code用于只查看某个审批定义下我发起的实例 |
| `start_timestamp` | 否 | 按发起时间筛选,时间范围开始值,秒级时间戳 |
| `end_timestamp` | 否 | 按发起时间筛选,时间范围结束值,秒级时间戳 |
| `locale` | 否 | 返回语言:`zh-CN``en-US``ja-JP` |
| `page_size` | 否 | 分页大小 |
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
@@ -101,6 +106,7 @@ lark-cli approval instances initiated \
- **这是定位“我发起的审批实例”的首选命令**:如果你的目标是撤回、抄送、查看某个已发起审批,优先从这里拿 `instance_code`
- **优先用 `definition_code` 缩小范围**:当你已知审批定义时,先筛掉无关实例,可显著提升可读性。
- **按时间排查时使用 `start_timestamp` / `end_timestamp`**:这两个值都是秒级时间戳,用于按发起时间缩小结果范围。
- **结果很多时优先 `--format table`**:适合人工快速浏览。
- **`count` 只在第一页返回**:做分页处理时不要假设后续页还会带总数。
- **`instance_status` 可直接判断下一步**:例如状态为 `1` 时通常可继续查看详情或考虑撤回,状态为 `4` 表示已经撤销,无需重复撤回。

View File

@@ -14,6 +14,9 @@ lark-cli approval tasks query --params '{"topic":"1"}' --as user
# 查询已办审批
lark-cli approval tasks query --params '{"topic":"2"}' --as user
# 按任务时间范围筛选(秒级时间戳)
lark-cli approval tasks query --params '{"topic":"1","start_timestamp":"<START_SECONDS>","end_timestamp":"<END_SECONDS>"}' --as user
# 使用 page_token 翻页
lark-cli approval tasks query --params '{"topic":"1","page_token":"example_page_token"}' --as user
@@ -28,6 +31,8 @@ lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
| `--params '{"topic":"..."}'` | 是 | 查询参数,使用 JSON 传入 |
| `topic` | 是 | 任务分组主题见下方“topic 枚举” |
| `definition_code` | 否 | 审批定义 Code用于仅查询某个审批定义下的任务 |
| `start_timestamp` | 否 | 按任务时间筛选,时间范围开始值,秒级时间戳 |
| `end_timestamp` | 否 | 按任务时间筛选,时间范围结束值,秒级时间戳 |
| `locale` | 否 | 返回语言:`zh-CN``en-US``ja-JP` |
| `page_size` | 否 | 分页大小 |
| `page_token` | 否 | 翻页标记;首次请求不填,后续使用上一次返回的 `page_token` |
@@ -67,10 +72,14 @@ lark-cli approval tasks query --params '{"topic":"1"}' --format table --as user
| `tasks[].summaries` | 表单摘要字段列表 |
| `tasks[].support_api_operate` | 是否支持通过 API 同意或拒绝该任务 |
| `tasks[].user_id` | 任务所属用户 ID |
| `tasks[].instance_external_id` | 三方审批实例 ID仅第三方审批实例存在 |
| `tasks[].task_external_id` | 三方审批任务 ID仅第三方审批任务存在 |
| `tasks[].link` | 三方审批跳转链接 |
## 使用建议
- 常见处理链:先用 `tasks query` 拿到 `task_id``instance_code`,若用户需要查看详情、当前节点、表单内容、流程进度等内容,则调用 `instances get` 查看详情,最后执行 `tasks approve` / `tasks reject` / `tasks transfer` / `tasks add_sign` / `tasks rollback`
- 如果你只想看“已发起的审批实例”,使用 `instances initiated``tasks query` 更适合围绕“任务分组”来拉取列表。
- 按时间排查任务时使用 `start_timestamp` / `end_timestamp` 缩小范围;这两个值都是秒级时间戳。
- 需要继续翻页时,直接把上一次返回的 `page_token` 放回 `--params`
- 当结果量较大时,优先使用 `--format table` 提升可读性。

View File

@@ -23,6 +23,12 @@ lark-cli approval tasks rollback \
--as user \
--yes
# 退回到发起节点(发起节点 ID 为 START
lark-cli approval tasks rollback \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["START"],"comment":"退回发起人补充材料"}' \
--as user \
--yes
# 传多个候选节点 ID以实际审批定义支持情况为准
lark-cli approval tasks rollback \
--data '{"instance_code":"<INSTANCE_CODE>","task_id":"<TASK_ID>","node_ids":["<NODE_ID_1>","<NODE_ID_2>"],"comment":"退回上一处理节点"}' \
@@ -43,7 +49,7 @@ lark-cli approval tasks rollback \
| `--data '{...}'` | 是 | 请求体 JSON使用 JSON 传入 |
| `instance_code` | 是 | 审批实例 Code通常先通过 `tasks query``instances initiated` / `instances get` 获取 |
| `task_id` | 是 | 审批任务 ID通常先通过 `tasks query` 获取 |
| `node_ids` | 是 | 退回目标节点 ID 数组;执行前应先确认这些节点确实可作为退回目标 |
| `node_ids` | 是 | 退回目标节点 ID 数组;发起节点 ID 为 `START`执行前应先确认这些节点确实可作为退回目标 |
| `comment` | 否 | 审批意见或退回说明,例如 `请补充附件后重新提交``预算说明不完整,请补充` |
| `--as user` | 否 | 建议显式指定用户身份;审批退回通常必须以用户身份执行 |
| `--yes` | 否 | 确认执行高风险写操作;未带时可能返回 `confirmation_required` / exit 10 |
@@ -75,7 +81,7 @@ lark-cli approval instances get --params '{"instance_code":"<INSTANCE_CODE>"}' -
## 使用建议
- **`instance_code``task_id` 要成对使用**:仅有实例 ID 或仅有任务 ID 都不足以准确执行退回操作。
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组。
- **`node_ids` 是必填项**:退回并不是“自动退回上一步”,而是要明确给出目标节点 ID 数组;退回发起节点时传 `START`
- **先确认节点是否可退回**:不同审批定义支持的退回目标可能不同;在不确定时,先通过 `instances get` 或业务侧流程信息核实。
- **优先从 `tasks query` 的待办列表拿任务参数**:尤其是 `topic=1` 的待办审批,最适合作为 rollback 的输入来源。
- **先检查是否支持 API 操作**:如果 `tasks[].support_api_operate``false`,说明该任务可能不支持通过 API 执行处理动作,退回前应谨慎验证。

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` 只适用于自定义角色,系统角色不可删除;删除角色和关闭高级权限前必须确认目标和影响。

Some files were not shown because too many files have changed in this diff Show More