Compare commits

...

33 Commits

Author SHA1 Message Date
梁硕
e34ee5bef9 chore: prepare v1.0.11 release metadata
Update the package version and changelog entry so the release branch matches the v1.0.11 changes already queued after v1.0.10.

This keeps the published package version and human-readable release notes aligned without pulling unrelated local workspace changes into the release PR.

Change-Id: Ia937651001e0057df4fe82bd11705c52d343f9a9
Constraint: Release PR should include only CHANGELOG.md and package.json
Rejected: Include .gitignore OMX ignore update | unrelated local-only change
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep release preparation PRs limited to versioned metadata unless a release-blocking code change is required
Tested: make unit-test; go mod tidy (no go.mod/go.sum changes); golangci-lint run --new-from-rev=origin/main
Not-tested: npm install/publish flow
2026-04-14 20:00:38 +08:00
kongenpei
052e2112bf fix: validate base shortcut JSON object inputs (#458)
* fix: validate base shortcut JSON object inputs

* fix: reject null in base JSON object parser

---------

Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-14 19:30:23 +08:00
caojie0621
76a834e928 feat(sheets): add dropdown shortcuts and formula reference docs (#461)
Implement +set-dropdown, +update-dropdown, +get-dropdown, and
+delete-dropdown shortcuts wrapping the v2 dataValidation API.
This resolves the issue where multipleValue writes silently
became plain text because the prerequisite dropdown configuration
step was not exposed as a CLI command.

Also add lark-sheets-formula.md reference for Lark-specific formula
rules (ARRAYFORMULA, native array functions, date diff, etc.) and
update the dropdown limitation note in SKILL.md to link to the new
+set-dropdown shortcut.
2026-04-14 18:48:07 +08:00
ILUO
20761fa56a feat(task): add task shortcuts with skill docs and tests (#377)
* feat(task): add task shortcuts with skill docs and tests

* docs(task): document task event payload shape

* refactor(task): remove unused buildUserIDs helper

* fix(task): handle api error codes in set-ancestor

* docs(task): clarify get-related-tasks page-token unit

* feat(task): support bot identity for subscribe-event

* docs(task): clarify bot subscribe-event scope

* docs(task): clarify related-task pagination semantics

* docs(task): add BOE selftest report (boe_task_tasklist_oapi_support)

* docs(task): prefer related-task shortcuts over search for scoped queries

* docs(task): clarify tasklist search routing

* docs(task): route keywordless tasklist queries to list API

* docs(task): refine search routing heuristics

* feat(event): include task user-access updates in catch-all subscribe

* docs(task): remove auth status --json guidance
2026-04-14 17:24:38 +08:00
mazhe-nerd
2a301246f9 feat: skip auth check (#451)
The secondary confirmation step in the interactive login process has been removed (Phase 2: After the user selects the complete domain name, permission level, and scope, they no longer need to confirm "authorize" again and can directly proceed to the authorization process).
2026-04-14 11:38:39 +08:00
Schumi Lin
abc374f1a3 docs(readme): add Attendance to Features table (#460)
* docs(readme): add lark-attendance to Agent Skills table and update counts

- Add lark-attendance to Agent Skills table in both EN and ZH READMEs
- Add Attendance to ZH Features table
- Update skill count 21 → 22 and domain count 13 → 14
2026-04-14 10:55:33 +08:00
caojie0621
2910cde73a feat(sheets): add value format documentation for formula and special types (#456)
Document the correct object format for writing formulas, URLs with
text, mentions, and dropdown lists via --values parameter. Add
examples contrasting correct object format vs incorrect plain string.
2026-04-14 00:07:45 +08:00
liangshuo-1
7fdc162ff7 chore: bump version to v1.0.10 and update changelog (#457)
Change-Id: I6f8f6b474e2bcedec4646c69b35235c52906c74e
2026-04-13 22:58:20 +08:00
chenxingtong-bytedance
06e7ae267c (im) support im oapi range download large file (#283)
Add range download support for IM OAPI resources so lark-cli can reliably download large files. This improves stability for large payloads and network interruptions.

Change-Id: I38e6f6f9cf8b8711dc40650d19c77503f4e44989
2026-04-13 22:02:34 +08:00
caojie0621
74f7de386a feat(sheets): add filter view and condition shortcuts (#422)
Add 10 new sheet shortcuts for filter view management:

Filter views:
- +create-filter-view, +update-filter-view, +list-filter-views
- +get-filter-view, +delete-filter-view

Filter view conditions:
- +create-filter-view-condition, +update-filter-view-condition
- +list-filter-view-conditions, +get-filter-view-condition
- +delete-filter-view-condition

Includes unit tests (39 cases, 88-93% coverage) and skill reference docs.
2026-04-13 21:41:28 +08:00
yaozhen00
c2b132945e feat(test): optimize cli-e2e-testcase-writer skill (#447)
* feat(test): optimize cli-e2e-testcase-writer skill add coverage.md

* feat(test): test report show
2026-04-13 21:10:11 +08:00
liujinkun2025
88fd3bdab8 feat(wiki): add wiki move shortcut with async task polling (#436)
Change-Id: I58400054e6c3c3c8e7b0cf72b874602b22fa287d
2026-04-13 19:33:53 +08:00
kongenpei
c70c3fdce2 fix: support large base attachment uploads (#441)
Co-authored-by: kongenpei <kongenpei@users.noreply.github.com>
2026-04-13 19:32:05 +08:00
MaxHuang22
c13f240b9b fix(config): clarify init copy for TTY, preserve original for AI (#448)
The interactive `config init` flow showed a QR code and verification
link without indicating their relationship, leaving users unsure
which to act on first and whether the link was still needed after
scanning.

Split the message strings on TTY vs non-TTY:
- TTY: header above QR ("使用飞书 / Lark 扫码配置应用"), "或打开链接"
  framing to mark the link as an alternative, and an active waiting
  indicator.
- Non-TTY (AI / piped callers via --new): keep the original copy
  verbatim so existing parsers and prompts are unaffected.

QR is still rendered in both branches.

Change-Id: I9b753f044ebefaedbb4b095cabf7beff4669eb2e
2026-04-13 18:51:38 +08:00
wittam-01
88bf7fc1cd feat: add drive files patch metaapi (#444)
Change-Id: Ieb5b11f004c6007813f48d4312a7d6e476bd6d79
2026-04-13 18:51:30 +08:00
haozhenghua-code
25534d72b5 fix(im): reject --user-id under bot identity for chat-messages-list (#340)
The chat_p2p/batch_query endpoint that resolves a user's p2p chat_id
requires user identity. Calling +chat-messages-list with --user-id
under bot identity previously failed silently or returned wrong
results.

- Validate: reject --user-id when runtime.IsBot(), with a hint to
  pass --as user or use --chat-id instead
- resolveP2PChatID: add defensive guard for the same condition in
  case the helper is reached via another path
- Update --user-id flag description and the lark-im skill reference
  to note the user-identity requirement
- Tests: add bot-rejection cases for Validate and resolveP2PChatID,
  switch p2p happy-path tests to a user-identity runtime helper
2026-04-13 17:54:10 +08:00
chenhuang
815db0c866 fix(mail): add missing scopes for mail +watch shortcut (#357)
* fix(mail): add missing event scope for mail watch

The mail +watch shortcut requires scope
mail:user_mailbox.event.mail_address:read to receive the mail_address
field in WebSocket event payloads, but this scope was neither declared
in the shortcut's Scopes list nor included in the auto-approve
(recommend.allow) set.

Without this scope, +watch events arrive without the mail_address field,
which breaks mailbox filtering and fetch-mailbox resolution.

- Add scope to mail +watch Scopes declaration
- Add scope to scope_overrides.json recommend.allow list so that
  auth login --recommend requests it automatically

* fix(mail): add missing mailbox profile scope for mail watch

The +watch shortcut calls fetchMailboxPrimaryEmail (GET
user_mailboxes/me/profile) to resolve the mailbox address for event
filtering, which requires scope mail:user_mailbox:readonly. All other
mail shortcuts that call this API (send, reply, forward, draft-create,
draft-edit) already declare this scope, but +watch did not.

* fix(mail): remove event scope from scope_overrides.json

The mail:user_mailbox.event.mail_address:read scope only needs to be
declared in the +watch shortcut's Scopes list, not in the global
recommend.allow set.
2026-04-13 17:22:28 +08:00
liujinkun2025
bb7957245b docs: add wiki member operations to lark-wiki skill (#417)
Change-Id: I5f8d930c25a650e26e7250269add2809b2b7f343
2026-04-13 14:33:14 +08:00
Tsai_Hui
3917b77e91 feat: add drive create-shortcut shortcut (#432) 2026-04-13 11:54:31 +08:00
wangzhengkui
dc0d92708b fix(mail): restrict --output-dir to current working directory (#376)
* fix(mail): restrict --output-dir to current working directory

Previously, mail +watch --output-dir accepted absolute paths (e.g.
/etc, /tmp) and home directory paths (~/), allowing writes to arbitrary
locations. Since mail content is sender-controlled, this posed a risk
of writing attacker-influenced data to sensitive system directories.

Now all --output-dir values go through validate.SafeOutputPath which:
- Rejects absolute paths and ~ expansion
- Resolves .. and symlinks
- Enforces the result stays under CWD

* fix(mail): reject tilde paths in --output-dir explicitly

SafeOutputPath treats ~/x as a literal relative path, silently creating
a directory named "~" under CWD. Reject ~ prefixed paths with a clear
error message instead.

* fix(mail): reject all tilde-prefixed paths and use ErrValidation

- Broaden ~ check from "~ || ~/" to "~" prefix, covering ~user/path forms
- Use output.ErrValidation for consistent error type (exit code 2)

* fix(mail): add post-mkdir EvalSymlinks + CWD re-verification (TOCTOU)

SafeOutputPath validates before MkdirAll, but an attacker could replace
the newly created directory with a symlink between mkdir and the first
write. Add EvalSymlinks after MkdirAll and re-verify the resolved path
is still under CWD.

Also broaden ~ rejection to all tilde-prefixed paths (~user/path) and
use output.ErrValidation for consistent error types.

* fix(mail): use validate.SafeOutputPath for post-mkdir TOCTOU check

Replace direct os.Getwd and filepath.EvalSymlinks calls with a second
SafeOutputPath call after MkdirAll. This satisfies the forbidigo lint
rule (no direct os/filepath calls in shortcuts/) while maintaining the
same TOCTOU protection.

* fix(mail): use original relative path for post-mkdir re-validation

SafeOutputPath rejects absolute paths, but after the first call
outputDir was already resolved to an absolute path. Pass the original
relative path to the second SafeOutputPath call so it can properly
re-validate after MkdirAll.

* fix(mail): remove redundant post-mkdir SafeOutputPath call

The second SafeOutputPath call after MkdirAll provided no real TOCTOU
protection: mail +watch is long-running, so the directory could be
replaced at any point during the session, not just between mkdir and
the check. The first SafeOutputPath already validates and resolves
the path; one call is sufficient.
2026-04-13 10:53:08 +08:00
Yuxuan Zhao
085ffd87f3 feat: add stable cli e2e tests (#401)
* feat: add stable bot-only cli e2e subset

Change-Id: I62edf59d179e407954f65f82e94cf5dcf4938080

* fix: address review comments on stable cli e2e tests

Change-Id: I4436100c30adf2694cd06953961f8d77f576fc1e

* fix: reduce flakiness in drive and im e2e helpers

Change-Id: I51e77d857f1fd9aec5ee34adf5045cc695239f21

* fix: document missing drive cleanup support

Change-Id: I3d4f034145bd69fb7640e707fcda05146b8754c7

* style: unify e2e cleanup comments

Change-Id: I40d906c9168754ad71ef9fb770ff4c340fc19beb

* test: update e2e assertions

Change-Id: I73c21b4b38d4ced7ea27cb327075957ec2b9a2a2

* test: stabilize cli e2e bot-only coverage

Change-Id: Ied897c37c4f42e446d55d110461aa34ae198195d
2026-04-12 16:52:41 +08:00
zero-my
f6b8091843 Feat/task section updates (#430)
* docs(task): document sections API resources and add URL parsing reminder

* feat(task): support --section-guid flag in tasklist-task-add shortcut

* docs(task): document sections API resources, permissions, and URL parsing
2026-04-12 16:12:16 +08:00
OwenYWT
0e7f507efb docs(lark-doc): clarify when markdown escaping is needed (#312)
* docs(lark-doc): clarify when markdown escaping is needed

* docs(lark-doc): fix escaped special character code span
2026-04-11 23:56:07 +08:00
liangshuo-1
1ff2dc578e chore: bump version to v1.0.9 and update changelog (#426)
Change-Id: I570d2f33d08c94d6df8daf78801be1bbcd252c3e
2026-04-11 22:18:31 +08:00
vanilla
69ae326d01 feat: add attendance user_task.query (#405)
Change-Id: Ie34b9b98859942ff368a9808fc2efab4d2bf27fa
2026-04-11 21:55:05 +08:00
ViperCai
e07842d3b5 feat(slides): return presentation URL in slides +create output (#425)
After creating the presentation, call drive batch_query (with_url=true)
to fetch the document URL and include it in the output. The fetch is
best-effort so it won't break creation if the API call fails.

Also update the skill reference doc to document the new optional url
return field.
2026-04-11 21:19:31 +08:00
ethan-zhx
a9c07cebb6 feat(slides): add slides +create shortcut with --slides one-step creation (#389)
Co-authored-by: caichengjie.viper <caichengjie.viper@bytedance.com>
2026-04-11 18:37:11 +08:00
caojie0621
f6a31e0853 feat(sheets): add dimension shortcuts for row/column operations (#413)
Add 5 new sheet shortcuts for row/column management:
- +add-dimension: append rows/columns at the end
- +insert-dimension: insert rows/columns at a position
- +update-dimension: update visibility and size
- +move-dimension: move rows/columns to a new position
- +delete-dimension: delete rows/columns

Includes unit tests (89-100% coverage) and skill reference docs.
2026-04-11 17:21:21 +08:00
liujinkun2025
bd5a33c0b7 feat(drive): add drive folder delete shortcut with async task polling (#415)
Change-Id: Ifb34f67296b800501a1b4960e02d5fed3382b84a
2026-04-11 16:47:03 +08:00
caojie0621
3242ca6f7f feat(sheets): add cell operation shortcuts for merge, replace, and style (#412)
Add 5 new sheet shortcuts for cell operations:
- +merge-cells: merge cells with MERGE_ALL/MERGE_ROWS/MERGE_COLUMNS
- +unmerge-cells: split merged cells
- +replace: find and replace cell values
- +set-style: set cell style (font, color, alignment, border)
- +batch-set-style: batch set styles for multiple ranges

Includes unit tests (81-89% coverage) and skill reference docs.
2026-04-11 16:45:14 +08:00
caojie0621
368ec7e753 docs(drive): add guide for granting document permission to current bot (#414) 2026-04-11 13:13:29 +08:00
liangshuo-1
9f81e7e567 feat: add RuntimeContext.BotInfo() for lazy bot identity retrieval (#409)
Add BotInfo() method on RuntimeContext that lazily fetches the current
app's bot open_id and display name from /bot/v3/info on first call,
cached via sync.OnceValues for the lifetime of the process.

- BotInfo struct (OpenID, AppName) in Identity section of runner.go
- fetchBotInfo() uses DoAPIAsBot for consistent header injection
- CanBot() on CliConfig gates the call when bot identity is unavailable
- Nil guard prevents panic in test contexts
- Full test coverage via httpmock.Registry + mounted shortcuts

Change-Id: I40ac710fb52d13939853f71827a5cbdbddd4f80f
2026-04-11 11:53:02 +08:00
zhicong666-bytedance
a00dfad56a feat: support minutes search (#359)
* feat: support minutes search by keyword and owner

* fix(minutes): align search output fields and clarify same-day queries

* fix(minutes): tighten search validation and output

* docs(vc): clarify recording usage examples

* test(minutes): remove redundant loop variable copies

* test(minutes): add docstrings for search tests

* refine minutes search params and skill routing

* minutes: refine search params payload and dry-run params feed

* skills: fix minutes search reference wording and vc link

* fix(minutes): align page-size cap to 30 and update tests

* skills: route meeting minutes lookup via vc first

* docs(skills): require shortcut reference reads
2026-04-11 06:31:10 +08:00
194 changed files with 23081 additions and 688 deletions

View File

@@ -25,6 +25,8 @@ on:
permissions:
contents: read
actions: read
checks: write
jobs:
cli-e2e:
@@ -65,71 +67,17 @@ jobs:
echo "No CLI E2E packages to test after exclusions."
exit 1
fi
go run gotest.tools/gotestsum@v1.12.3 --format testname --junitfile cli-e2e-report.xml -- -count=1 -v $packages
# gotestsum requires --packages when --rerun-fails is combined with go test args after --.
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -)
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages_arg" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Summarize CLI E2E test report
- name: Publish CLI E2E test report
if: ${{ !cancelled() }}
run: |
python3 - <<'PY'
import os
import xml.etree.ElementTree as ET
report_path = "cli-e2e-report.xml"
summary_path = os.environ["GITHUB_STEP_SUMMARY"]
root = ET.parse(report_path).getroot()
suites = [root] if root.tag == "testsuite" else root.findall("testsuite")
tests = failures = errors = skipped = 0
failed_cases = []
skipped_cases = []
for suite in suites:
tests += int(suite.attrib.get("tests", 0))
failures += int(suite.attrib.get("failures", 0))
errors += int(suite.attrib.get("errors", 0))
skipped += int(suite.attrib.get("skipped", 0))
for case in suite.findall("testcase"):
classname = case.attrib.get("classname", "")
name = case.attrib.get("name", "")
label = f"{classname}.{name}" if classname else name
failure = case.find("failure")
error = case.find("error")
skipped_node = case.find("skipped")
if failure is not None or error is not None:
message = ""
node = failure if failure is not None else error
if node is not None:
message = node.attrib.get("message", "") or (node.text or "").strip()
failed_cases.append((label, message))
elif skipped_node is not None:
message = skipped_node.attrib.get("message", "") or (skipped_node.text or "").strip()
skipped_cases.append((label, message))
passed = tests - failures - errors - skipped
with open(summary_path, "a", encoding="utf-8") as f:
f.write("## CLI E2E Test Report\n\n")
f.write(f"- Total: {tests}\n")
f.write(f"- Passed: {passed}\n")
f.write(f"- Failed: {failures}\n")
f.write(f"- Errors: {errors}\n")
f.write(f"- Skipped: {skipped}\n\n")
if failed_cases:
f.write("### Failed Tests\n\n")
for label, message in failed_cases:
detail = f" - {message}" if message else ""
f.write(f"- `{label}`{detail}\n")
f.write("\n")
if skipped_cases:
f.write("### Skipped Tests\n\n")
for label, message in skipped_cases:
detail = f" - {message}" if message else ""
f.write(f"- `{label}`{detail}\n")
f.write("\n")
PY
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with:
name: CLI E2E Tests
path: cli-e2e-report.xml
reporter: java-junit
use-actions-summary: true
list-suites: all
list-tests: all

1
.gitignore vendored
View File

@@ -35,3 +35,4 @@ tests/mail/reports/
# Generated / test artifacts
internal/registry/meta_data.json
cmd/api/download.bin
app.log

View File

@@ -2,6 +2,64 @@
All notable changes to this project will be documented in this file.
## [v1.0.11] - 2026-04-14
### Features
- **sheets**: Add dropdown shortcuts for data validation management (`+set-dropdown`, `+update-dropdown`, `+get-dropdown`, `+delete-dropdown`) (#461)
- **task**: Add task search, tasklist search, related-task, set-ancestor, and subscribe-event shortcuts (#377)
- Streamline interactive login by removing the extra auth confirmation step (#451)
### Bug Fixes
- **base**: Validate JSON object inputs for base shortcuts and reject `null` objects (#458)
### Documentation
- **sheets**: Document value formats for formulas and special field types (#456)
- **readme**: Add Attendance to the features table (#460)
## [v1.0.10] - 2026-04-13
### Features
- **im**: Support im oapi range download for large files (#283)
- **sheets**: Add filter view and condition shortcuts (#422)
- **wiki**: Add wiki move shortcut with async task polling (#436)
- **drive**: Add drive `+create-shortcut` shortcut (#432)
- **drive**: Add drive files patch metadata API (#444)
- **task**: Support `--section-guid` flag in tasklist-task-add shortcut (#430)
### Bug Fixes
- **base**: Support large base attachment uploads (#441)
- **config**: Clarify init copy for TTY, preserve original for AI (#448)
- **im**: Reject `--user-id` under bot identity for chat-messages-list (#340)
- **mail**: Add missing scopes for mail `+watch` shortcut (#357)
- **mail**: Restrict `--output-dir` to current working directory (#376)
### Documentation
- **wiki**: Add wiki member operations to lark-wiki skill (#417)
- **task**: Document sections API resources, permissions, and URL parsing (#430)
- **doc**: Clarify when markdown escaping is needed (#312)
## [v1.0.9] - 2026-04-11
### Features
- Add attendance `user_task.query` (#405)
- Support minutes search (#359)
- **slides**: Add slides `+create` shortcut with `--slides` one-step creation (#389)
- **slides**: Return presentation URL in slides `+create` output (#425)
- **sheets**: Add dimension shortcuts for row/column operations (#413)
- **sheets**: Add cell operation shortcuts for merge, replace, and style (#412)
- **drive**: Add drive folder delete shortcut with async task polling (#415)
### Documentation
- **drive**: Add guide for granting document permission to current bot (#414)
## [v1.0.8] - 2026-04-10
### Features
@@ -287,6 +345,10 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.11]: https://github.com/larksuite/cli/releases/tag/v1.0.11
[v1.0.10]: https://github.com/larksuite/cli/releases/tag/v1.0.10
[v1.0.9]: https://github.com/larksuite/cli/releases/tag/v1.0.9
[v1.0.8]: https://github.com/larksuite/cli/releases/tag/v1.0.8
[v1.0.7]: https://github.com/larksuite/cli/releases/tag/v1.0.7
[v1.0.6]: https://github.com/larksuite/cli/releases/tag/v1.0.6
[v1.0.5]: https://github.com/larksuite/cli/releases/tag/v1.0.5

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 20 AI Agent [Skills](./skills/).
The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by the [larksuite](https://github.com/larksuite) team — built for humans and AI Agents. Covers core business domains including Messenger, Docs, Base, Sheets, Slides, Calendar, Mail, Tasks, Meetings, and more, with 200+ commands and 22 AI Agent [Skills](./skills/).
[Install](#installation--quick-start) · [AI Agent Skills](#agent-skills) · [Auth](#authentication) · [Commands](#three-layer-command-system) · [Advanced](#advanced-usage) · [Security](#security--risk-warnings-read-before-use) · [Contributing](#contributing)
## Why lark-cli?
- **Agent-Native Design** — 20 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 12 business domains, 200+ curated commands, 20 AI Agent [Skills](./skills/)
- **Agent-Native Design** — 22 structured [Skills](./skills/) out of the box, compatible with popular AI tools — Agents can operate Lark with zero extra setup
- **Wide Coverage** — 14 business domains, 200+ curated commands, 22 AI Agent [Skills](./skills/)
- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output to maximize Agent call success rates
- **Open Source, Zero Barriers** — MIT license, ready to use, just `npm install`
- **Up and Running in 3 Minutes** — One-click app creation, interactive login, from install to first API call in just 3 steps
@@ -30,11 +30,13 @@ The official [Lark/Feishu](https://www.larksuite.com/) CLI tool, maintained by t
| 📁 Drive | Upload and download files, search docs & wiki, manage comments |
| 📊 Base | Create and manage tables, fields, records, views, dashboards, workflows, forms, roles & permissions, data aggregation & analytics |
| 📈 Sheets | Create, read, write, append, find, and export spreadsheet data |
| 🖼️ Slides | Create and manage presentations, read presentation content, and add or remove slides |
| ✅ Tasks | Create, query, update, and complete tasks; manage task lists, subtasks, comments & reminders |
| 📚 Wiki | Create and manage knowledge spaces, nodes, and documents |
| 👤 Contact | Search users by name/email/phone, get user profiles |
| 📧 Mail | Browse, search, read emails, send, reply, forward, manage drafts, watch new mail |
| 🎥 Meetings | Search meeting records, query meeting minutes & recordings |
| 🕐 Attendance | Query personal attendance check-in records |
| ✍️ Approval | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
## Installation & Quick Start
@@ -136,6 +138,7 @@ lark-cli auth status
| `lark-doc` | Create, read, update, search documents (Markdown-based) |
| `lark-drive` | Upload, download files, manage permissions & comments |
| `lark-sheets` | Create, read, write, append, find, export spreadsheets |
| `lark-slides` | Create and manage presentations, read presentation content, and add or remove slides |
| `lark-base` | Tables, fields, records, views, dashboards, data aggregation & analytics |
| `lark-task` | Tasks, task lists, subtasks, reminders, member assignment |
| `lark-mail` | Browse, search, read emails, send, reply, forward, draft management, watch new mail |
@@ -147,6 +150,7 @@ lark-cli auth status
| `lark-minutes` | Minutes metadata & AI artifacts (summary, todos, chapters) |
| `lark-openapi-explorer` | Explore underlying APIs from official docs |
| `lark-skill-maker` | Custom skill creation framework |
| `lark-attendance` | Query personal attendance check-in records |
| `lark-approval` | Query approval tasks, approve/reject/transfer tasks, cancel and CC instances |
| `lark-workflow-meeting-summary` | Workflow: meeting minutes aggregation & structured report |
| `lark-workflow-standup-report` | Workflow: agenda & todo summary |

View File

@@ -6,14 +6,14 @@
[中文版](./README.zh.md) | [English](./README.md)
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 20 个 AI Agent [Skills](./skills/)。
飞书官方 CLI 工具,由 [larksuite](https://github.com/larksuite) 团队维护 — 让人类和 AI Agent 都能在终端中操作飞书。覆盖消息、文档、多维表格、电子表格、幻灯片、日历、邮箱、任务、会议等核心业务域,提供 200+ 命令及 22 个 AI Agent [Skills](./skills/)。
[安装](#安装与快速开始) · [AI Agent Skills](#agent-skills) · [认证](#认证) · [命令](#三层命令调用) · [进阶用法](#进阶用法) · [安全](#安全与风险提示使用前必读) · [贡献](#贡献)
## 为什么选 lark-cli
- **为 Agent 原生设计** — [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 12 大业务域、200+ 精选命令、 20 个 AI Agent [Skills](./skills/)
- **为 Agent 原生设计** — 22 个 [Skills](./skills/) 开箱即用,适配主流 AI 工具Agent 无需额外适配即可操作飞书
- **覆盖面广** — 14 大业务域、200+ 精选命令、22 个 AI Agent [Skills](./skills/)
- **AI 友好调优** — 每条命令经过 Agent 实测验证,提供更友好的参数、智能默认值和结构化输出,大幅提升 Agent 调用成功率
- **开源零门槛** — MIT 协议,开箱即用,`npm install` 即可使用
- **三分钟上手** — 一键创建应用、交互式登录授权,从安装到第一次 API 调用只需三步
@@ -30,11 +30,13 @@
| 📁 云空间 | 上传和下载文件、搜索文档与知识库、管理评论 |
| 📊 多维表格 | 创建和管理数据表、字段、记录、视图、仪表盘、自动化流程、表单、角色权限,数据聚合分析 |
| 📈 电子表格 | 创建、读取、写入、追加、查找和导出表格数据 |
| 🖼️ 幻灯片 | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| ✅ 任务 | 创建、查询、更新和完成任务;管理任务清单、子任务、评论与提醒 |
| 📚 知识库 | 创建和管理知识空间、节点和文档 |
| 👤 通讯录 | 按姓名/邮箱/手机号搜索用户、获取用户信息 |
| 📧 邮箱 | 浏览、搜索、阅读邮件,发送、回复、转发邮件,管理草稿,监听新邮件 |
| 🎥 视频会议 | 搜索会议记录、查询会议纪要与录制 |
| 🕐 考勤打卡 | 查询个人考勤打卡记录 |
| ✍️ 审批 | 查询审批任务、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
## 安装与快速开始
@@ -137,6 +139,7 @@ lark-cli auth status
| `lark-doc` | 创建、读取、更新、搜索文档(基于 Markdown |
| `lark-drive` | 上传、下载文件,管理权限与评论 |
| `lark-sheets` | 创建、读取、写入、追加、查找、导出电子表格 |
| `lark-slides` | 创建和管理演示文稿、读取演示文稿内容,以及新增或删除幻灯片页面 |
| `lark-base` | 多维表格、字段、记录、视图、仪表盘、数据聚合分析 |
| `lark-task` | 任务、任务清单、子任务、提醒、成员分配 |
| `lark-mail` | 浏览、搜索、阅读邮件,发送、回复、转发,草稿管理,监听新邮件 |
@@ -148,6 +151,7 @@ lark-cli auth status
| `lark-minutes` | 妙记元数据与 AI 产物(总结、待办、章节) |
| `lark-openapi-explorer` | 从官方文档探索底层 API |
| `lark-skill-maker` | 自定义 skill 创建框架 |
| `lark-attendance` | 查询个人考勤打卡记录 |
| `lark-approval` | 审批任务查询、同意/拒绝/转交审批任务、撤回与抄送审批实例 |
| `lark-workflow-meeting-summary` | 工作流:会议纪要汇总与结构化报告 |
| `lark-workflow-standup-report` | 工作流:日程待办摘要 |

View File

@@ -184,27 +184,6 @@ func runInteractiveLogin(ios *cmdutil.IOStreams, lang string, msg *loginMsg) (*i
}
fmt.Fprintf(ios.ErrOut, msg.SummaryScopes, len(scopes), scopePreview)
// Phase 2: confirmation
var confirmed bool
form2 := huh.NewForm(
huh.NewGroup(
huh.NewConfirm().
Title(msg.ConfirmAuth).
Value(&confirmed),
),
).WithTheme(cmdutil.ThemeFeishu())
if err := form2.Run(); err != nil {
if err == huh.ErrUserAborted {
return nil, output.ErrBare(1)
}
return nil, err
}
if !confirmed {
return nil, output.ErrBare(1)
}
return &interactiveResult{
Domains: selectedDomains,
ScopeLevel: permLevel,

View File

@@ -177,17 +177,26 @@ func runCreateAppFlow(ctx context.Context, f *cmdutil.Factory, brandOverride cor
// Step 2: Build and display verification URL + QR code
verificationURL := larkauth.BuildVerificationURL(authResp.VerificationUriComplete, build.Version)
// Show QR code in terminal
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
// Branch on TTY: human-friendly copy in interactive terminals,
// preserve original copy for AI / non-interactive callers.
if f.IOStreams.IsTerminal {
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanQRCode)
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
} else {
qr, qrErr := qrcode.New(verificationURL, qrcode.Medium)
if qrErr == nil {
fmt.Fprint(f.IOStreams.ErrOut, qr.ToSmallString(false))
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.OpenLinkNonTTY)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScanNonTTY)
}
fmt.Fprintf(f.IOStreams.ErrOut, "%s", msg.ScanOrOpenLink)
fmt.Fprintf(f.IOStreams.ErrOut, " %s\n\n", verificationURL)
// Step 3: Poll for result
fmt.Fprintf(f.IOStreams.ErrOut, "%s\n", msg.WaitingForScan)
result, err := larkauth.PollAppRegistration(ctx, httpClient, core.BrandFeishu, authResp.DeviceCode, authResp.Interval, authResp.ExpiresIn, f.IOStreams.ErrOut)
if err != nil {
return nil, output.ErrAuth("%v", err)

View File

@@ -16,11 +16,16 @@ type initMsg struct {
Platform string
SelectPlatform string
Feishu string
ScanOrOpenLink string
WaitingForScan string
DetectedLarkTenant string
AppCreated string
ConfigSaved string
// TTY (interactive) variants
ScanQRCode string // header shown above QR code
ScanOrOpenLink string // post-QR alt link prompt ("or open...")
WaitingForScan string // active polling indicator
// Non-TTY (AI / non-interactive) variants — preserve original copy
OpenLinkNonTTY string // primary link prompt
WaitingForScanNonTTY string // passive waiting indicator
DetectedLarkTenant string
AppCreated string
ConfigSaved string
}
var initMsgZh = &initMsg{
@@ -29,12 +34,15 @@ var initMsgZh = &initMsg{
ConfigExistingApp: "手动输入应用凭证",
Platform: "平台",
SelectPlatform: "选择平台",
Feishu: "飞书",
ScanOrOpenLink: "\n打开以下链接配置应用:\n\n",
WaitingForScan: "等待配置应用...",
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
Feishu: "飞书",
ScanQRCode: "\n使用飞书 / Lark 扫码配置应用\n\n",
ScanOrOpenLink: "\n或打开以下链接完成配置\n",
WaitingForScan: "正在获取你的应用配置结果...",
OpenLinkNonTTY: "\n打开以下链接配置应用:\n\n",
WaitingForScanNonTTY: "等待配置应用...",
DetectedLarkTenant: "[lark-cli] 检测到 Lark 租户,切换端点重试...",
AppCreated: "应用配置成功! App ID: %s",
ConfigSaved: "应用配置成功! App ID: %s",
}
var initMsgEn = &initMsg{
@@ -43,12 +51,15 @@ var initMsgEn = &initMsg{
ConfigExistingApp: "Enter app credentials yourself",
Platform: "Platform",
SelectPlatform: "Select platform",
Feishu: "Feishu",
ScanOrOpenLink: "\nOpen the link below to configure app:\n\n",
WaitingForScan: "Waiting for app configuration...",
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
Feishu: "Feishu",
ScanQRCode: "\nScan the QR code with Feishu/Lark:\n\n",
ScanOrOpenLink: "\nOr open the link below in your browser:\n",
WaitingForScan: "Fetching configuration results...",
OpenLinkNonTTY: "\nOpen the link below to configure app:\n\n",
WaitingForScanNonTTY: "Waiting for app configuration...",
DetectedLarkTenant: "[lark-cli] Detected Lark tenant, switching endpoint...",
AppCreated: "App configured! App ID: %s",
ConfigSaved: "App configured! App ID: %s",
}
func getInitMsg(lang string) *initMsg {

View File

@@ -54,11 +54,14 @@ func assertAllFieldsNonEmpty(t *testing.T, msg *initMsg, label string) {
"Platform": msg.Platform,
"SelectPlatform": msg.SelectPlatform,
"Feishu": msg.Feishu,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
"ScanQRCode": msg.ScanQRCode,
"ScanOrOpenLink": msg.ScanOrOpenLink,
"WaitingForScan": msg.WaitingForScan,
"OpenLinkNonTTY": msg.OpenLinkNonTTY,
"WaitingForScanNonTTY": msg.WaitingForScanNonTTY,
"DetectedLarkTenant": msg.DetectedLarkTenant,
"AppCreated": msg.AppCreated,
"ConfigSaved": msg.ConfigSaved,
}
for name, val := range fields {
if val == "" {

View File

@@ -163,6 +163,16 @@ type CliConfig struct {
SupportedIdentities uint8 `json:"-"` // bitflag: 1=user, 2=bot; set by credential provider
}
// identityBotBit is the bit flag for bot identity in SupportedIdentities.
// Must match extension/credential.SupportsBot.
const identityBotBit uint8 = 1 << 1
// CanBot reports whether the current credential context supports bot identity.
// Returns true when SupportedIdentities is unset (0, unknown) or includes the bot bit.
func (c *CliConfig) CanBot() bool {
return c.SupportedIdentities == 0 || c.SupportedIdentities&identityBotBit != 0
}
// GetConfigDir returns the config directory path.
// If the home directory cannot be determined, it falls back to a relative path
// and prints a warning to stderr.

View File

@@ -187,3 +187,24 @@ func TestResolveConfigFromMulti_DoesNotUseEnvProfileFallback(t *testing.T) {
t.Fatalf("ResolveConfigFromMulti() profile = %q, want %q", cfg.ProfileName, "active")
}
}
func TestCliConfig_CanBot(t *testing.T) {
tests := []struct {
name string
supportedIdentities uint8
want bool
}{
{"unset (0) defaults to true", 0, true},
{"user only", 1, false},
{"bot only", 2, true},
{"both", 3, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &CliConfig{SupportedIdentities: tt.supportedIdentities}
if got := cfg.CanBot(); got != tt.want {
t.Errorf("CanBot() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -33,6 +33,11 @@ const (
LarkErrRefreshRevoked = 20064 // refresh_token revoked
LarkErrRefreshAlreadyUsed = 20073 // refresh_token already consumed (single-use rotation)
LarkErrRefreshServerError = 20050 // refresh endpoint server-side error, retryable
// Drive shortcut / cross-space constraints.
LarkErrDriveResourceContention = 1061045 // resource contention occurred, please retry
LarkErrDriveCrossTenantUnit = 1064510 // cross tenant and unit not support
LarkErrDriveCrossBrand = 1064511 // cross brand not support
)
// ClassifyLarkError maps a Lark API error code + message to (exitCode, errType, hint).
@@ -60,6 +65,14 @@ func ClassifyLarkError(code int, msg string) (int, string, string) {
// rate limit
case LarkErrRateLimit:
return ExitAPI, "rate_limit", "please try again later"
// drive-specific constraints that benefit from actionable hints
case LarkErrDriveResourceContention:
return ExitAPI, "conflict", "please retry later and avoid concurrent duplicate requests"
case LarkErrDriveCrossTenantUnit:
return ExitAPI, "cross_tenant_unit", "operate on source and target within the same tenant and region/unit"
case LarkErrDriveCrossBrand:
return ExitAPI, "cross_brand", "operate on source and target within the same brand environment"
}
return ExitAPI, "api_error", ""

View File

@@ -0,0 +1,64 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package output
import (
"strings"
"testing"
)
// TestClassifyLarkError_DriveCreateShortcutConstraints verifies known Drive shortcut errors map to actionable hints.
func TestClassifyLarkError_DriveCreateShortcutConstraints(t *testing.T) {
t.Parallel()
tests := []struct {
name string
code int
wantExitCode int
wantType string
wantHint string
}{
{
name: "resource contention",
code: LarkErrDriveResourceContention,
wantExitCode: ExitAPI,
wantType: "conflict",
wantHint: "avoid concurrent duplicate requests",
},
{
name: "cross tenant unit",
code: LarkErrDriveCrossTenantUnit,
wantExitCode: ExitAPI,
wantType: "cross_tenant_unit",
wantHint: "same tenant and region/unit",
},
{
name: "cross brand",
code: LarkErrDriveCrossBrand,
wantExitCode: ExitAPI,
wantType: "cross_brand",
wantHint: "same brand environment",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
gotExitCode, gotType, gotHint := ClassifyLarkError(tt.code, "raw msg")
if gotExitCode != tt.wantExitCode {
t.Fatalf("exitCode=%d, want %d", gotExitCode, tt.wantExitCode)
}
if gotType != tt.wantType {
t.Fatalf("type=%q, want %q", gotType, tt.wantType)
}
if gotHint == "" {
t.Fatal("expected non-empty hint")
}
if !strings.Contains(gotHint, tt.wantHint) {
t.Fatalf("hint=%q, want substring %q", gotHint, tt.wantHint)
}
})
}
}

View File

@@ -43,6 +43,10 @@
"en": { "title": "Sheets", "description": "Spreadsheet operations" },
"zh": { "title": "电子表格", "description": "电子表格操作" }
},
"slides": {
"en": { "title": "Slides", "description": "Create and manage presentations, read content, and add or remove slides" },
"zh": { "title": "幻灯片", "description": "创建和管理演示文稿、读取内容,以及新增或删除幻灯片页面" }
},
"task": {
"en": { "title": "Task", "description": "Task, task list, and subtask management" },
"zh": { "title": "任务", "description": "任务、清单、子任务管理" }

View File

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

View File

@@ -151,6 +151,87 @@ func TestBaseFieldExecuteUpdate(t *testing.T) {
}
}
func TestBaseObjectJSONShortcutsRejectArrayInDryRun(t *testing.T) {
tests := []struct {
name string
shortcut common.Shortcut
args []string
}{
{
name: "field create",
shortcut: BaseFieldCreate,
args: []string{"+field-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "field update",
shortcut: BaseFieldUpdate,
args: []string{"+field-update", "--base-token", "app_x", "--table-id", "tbl_x", "--field-id", "fld_x", "--json", `[]`, "--dry-run"},
},
{
name: "record search",
shortcut: BaseRecordSearch,
args: []string{"+record-search", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "record upsert",
shortcut: BaseRecordUpsert,
args: []string{"+record-upsert", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "record batch create",
shortcut: BaseRecordBatchCreate,
args: []string{"+record-batch-create", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "record batch update",
shortcut: BaseRecordBatchUpdate,
args: []string{"+record-batch-update", "--base-token", "app_x", "--table-id", "tbl_x", "--json", `[]`, "--dry-run"},
},
{
name: "view set filter",
shortcut: BaseViewSetFilter,
args: []string{"+view-set-filter", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
},
{
name: "view set visible fields",
shortcut: BaseViewSetVisibleFields,
args: []string{"+view-set-visible-fields", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
},
{
name: "view set card",
shortcut: BaseViewSetCard,
args: []string{"+view-set-card", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
},
{
name: "view set timebar",
shortcut: BaseViewSetTimebar,
args: []string{"+view-set-timebar", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[]`, "--dry-run"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
err := runShortcut(t, tt.shortcut, tt.args, factory, stdout)
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if !strings.Contains(err.Error(), "lark-base skill") {
t.Fatalf("err=%v", err)
}
if strings.Contains(err.Error(), "array") {
t.Fatalf("err should not mention array: %v", err)
}
if got := stdout.String(); got != "" {
t.Fatalf("stdout=%q, want empty", got)
}
})
}
}
func TestBaseTableExecuteCreate(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
reg.Register(&httpmock.Stub{
@@ -259,7 +340,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
"data": []interface{}{map[string]interface{}{"field": "fld_status", "desc": false}},
},
})
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_status","desc":false}]`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseViewSetGroup, []string{"+view-set-group", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"group_config":[{"field":"fld_status","desc":false}]}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"group"`) || !strings.Contains(got, `"fld_status"`) {
@@ -277,7 +358,7 @@ func TestBaseViewExecutePropertyActions(t *testing.T) {
"data": []interface{}{map[string]interface{}{"field": "fld_amount", "desc": true}},
},
})
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `[{"field":"fld_amount","desc":true}]`}, factory, stdout); err != nil {
if err := runShortcut(t, BaseViewSetSort, []string{"+view-set-sort", "--base-token", "app_x", "--table-id", "tbl_x", "--view-id", "vew_x", "--json", `{"sort_config":[{"field":"fld_amount","desc":true}]}`}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"sort"`) || !strings.Contains(got, `"fld_amount"`) {
@@ -865,6 +946,157 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
}
})
t.Run("upload attachment uses multipart for large file", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
tmpFile, err := os.CreateTemp(t.TempDir(), "base-attachment-large-*.bin")
if err != nil {
t.Fatalf("CreateTemp() err=%v", err)
}
if err := tmpFile.Truncate(common.MaxDriveMediaUploadSinglePartSize + 1); err != nil {
t.Fatalf("Truncate() err=%v", err)
}
if err := tmpFile.Close(); err != nil {
t.Fatalf("Close() err=%v", err)
}
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/fields/fld_att",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"id": "fld_att", "name": "附件", "type": "attachment"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id": "rec_x",
"fields": map[string]interface{}{},
},
},
})
prepareStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_prepare",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"upload_id": "upload_big_1",
"block_size": float64(8 * 1024 * 1024),
"block_num": float64(3),
},
},
}
reg.Register(prepareStub)
partStubs := make([]*httpmock.Stub, 0, 3)
for i := 0; i < 3; i++ {
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_part",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
},
}
partStubs = append(partStubs, stub)
reg.Register(stub)
}
finishStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/medias/upload_finish",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"file_token": "file_tok_big"},
},
}
reg.Register(finishStub)
updateStub := &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/base/v3/bases/app_x/tables/tbl_x/records/rec_x",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"record_id": "rec_x",
"fields": map[string]interface{}{
"附件": []interface{}{
map[string]interface{}{
"file_token": "file_tok_big",
"name": "large-report.bin",
"deprecated_set_attachment": true,
},
},
},
},
},
}
reg.Register(updateStub)
if err := runShortcut(t, BaseRecordUploadAttachment, []string{
"+record-upload-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--field-id", "fld_att",
"--file", "./" + filepath.Base(tmpFile.Name()),
"--name", "large-report.bin",
}, factory, stdout); err != nil {
t.Fatalf("err=%v", err)
}
if got := stdout.String(); !strings.Contains(got, `"updated": true`) || !strings.Contains(got, `"file_tok_big"`) || !strings.Contains(got, `"large-report.bin"`) {
t.Fatalf("stdout=%s", got)
}
prepareBody := string(prepareStub.CapturedBody)
if !strings.Contains(prepareBody, `"file_name":"large-report.bin"`) ||
!strings.Contains(prepareBody, `"parent_type":"bitable_file"`) ||
!strings.Contains(prepareBody, `"parent_node":"app_x"`) ||
!strings.Contains(prepareBody, `"size":20971521`) {
t.Fatalf("prepare body=%s", prepareBody)
}
firstPartBody := string(partStubs[0].CapturedBody)
if !strings.Contains(firstPartBody, `name="upload_id"`) ||
!strings.Contains(firstPartBody, "upload_big_1") ||
!strings.Contains(firstPartBody, `name="seq"`) ||
!strings.Contains(firstPartBody, "\r\n0\r\n") ||
!strings.Contains(firstPartBody, `name="size"`) ||
!strings.Contains(firstPartBody, "8388608") {
t.Fatalf("first part body=%s", firstPartBody)
}
lastPartBody := string(partStubs[2].CapturedBody)
if !strings.Contains(lastPartBody, `name="seq"`) ||
!strings.Contains(lastPartBody, "\r\n2\r\n") ||
!strings.Contains(lastPartBody, `name="size"`) ||
!strings.Contains(lastPartBody, "4194305") {
t.Fatalf("last part body=%s", lastPartBody)
}
finishBody := string(finishStub.CapturedBody)
if !strings.Contains(finishBody, `"upload_id":"upload_big_1"`) ||
!strings.Contains(finishBody, `"block_num":3`) {
t.Fatalf("finish body=%s", finishBody)
}
updateBody := string(updateStub.CapturedBody)
if !strings.Contains(updateBody, `"附件"`) ||
!strings.Contains(updateBody, `"file_token":"file_tok_big"`) ||
!strings.Contains(updateBody, `"name":"large-report.bin"`) ||
!strings.Contains(updateBody, `"deprecated_set_attachment":true`) {
t.Fatalf("update body=%s", updateBody)
}
})
t.Run("upload attachment rejects non-attachment field", func(t *testing.T) {
factory, stdout, reg := newExecuteFactory(t)
@@ -904,6 +1136,37 @@ func TestBaseRecordExecuteReadCreateDelete(t *testing.T) {
t.Fatalf("err=%v", err)
}
})
t.Run("upload attachment rejects file larger than 2GB", func(t *testing.T) {
factory, stdout, _ := newExecuteFactory(t)
tmpFile, err := os.CreateTemp(t.TempDir(), "base-too-large-*.bin")
if err != nil {
t.Fatalf("CreateTemp() err=%v", err)
}
if err := tmpFile.Truncate(2*1024*1024*1024 + 1); err != nil {
t.Fatalf("Truncate() err=%v", err)
}
if err := tmpFile.Close(); err != nil {
t.Fatalf("Close() err=%v", err)
}
withBaseWorkingDir(t, filepath.Dir(tmpFile.Name()))
err = runShortcut(t, BaseRecordUploadAttachment, []string{
"+record-upload-attachment",
"--base-token", "app_x",
"--table-id", "tbl_x",
"--record-id", "rec_x",
"--field-id", "fld_att",
"--file", "./" + filepath.Base(tmpFile.Name()),
}, factory, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "exceeds 2GB limit") {
t.Fatalf("err=%v", err)
}
})
}
func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
@@ -1021,7 +1284,7 @@ func TestBaseViewExecuteReadCreateDeleteAndFilter(t *testing.T) {
factory,
stdout,
)
if err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
if err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
})

View File

@@ -63,7 +63,7 @@ func loadJSONInput(pc *parseCtx, raw string, flagName string) (string, error) {
}
func jsonInputTip(flagName string) string {
return fmt.Sprintf("tip: pass a JSON object/array directly, or use --%s @path/to/file.json", flagName)
return fmt.Sprintf("tip: pass a valid JSON directly, or use --%s @file.json; use the lark-base skill or this command's reference to find the expected body", flagName)
}
func formatJSONError(flagName string, target string, err error) error {

View File

@@ -120,9 +120,9 @@ func TestWrapViewPropertyBody(t *testing.T) {
}
}
func TestViewSetVisibleFieldsNoValidateHook(t *testing.T) {
if BaseViewSetVisibleFields.Validate != nil {
t.Fatalf("expected no validate hook, got non-nil")
func TestViewSetVisibleFieldsValidateHook(t *testing.T) {
if BaseViewSetVisibleFields.Validate == nil {
t.Fatal("expected validate hook")
}
}
@@ -212,8 +212,8 @@ func TestBaseFieldUpdateHelpHidesReadGuideFlag(t *testing.T) {
func TestBaseFieldValidate(t *testing.T) {
ctx := context.Background()
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err != nil {
t.Fatalf("invalid json should bypass CLI validate, err=%v", err)
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseFieldCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "t", "json": `{"name":"f1","type":"formula"}`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--i-have-read-guide is required") {
t.Fatalf("err=%v", err)
@@ -255,22 +255,29 @@ func TestBaseRecordValidate(t *testing.T) {
if BaseRecordList.Validate != nil {
t.Fatalf("record list validate should be nil for repeatable --field-id")
}
if BaseRecordSearch.Validate != nil {
t.Fatalf("record search validate should be nil for API passthrough")
if BaseRecordSearch.Validate == nil {
t.Fatalf("record search validate should reject invalid JSON before dry-run")
}
if BaseRecordGet.Validate != nil {
t.Fatalf("record get validate should be nil")
}
if BaseRecordUpsert.Validate != nil {
t.Fatalf("record upsert validate should be nil for API passthrough")
if BaseRecordUpsert.Validate == nil {
t.Fatalf("record upsert validate should reject invalid JSON before dry-run")
}
}
func TestBaseViewValidate(t *testing.T) {
ctx := context.Background()
if err := BaseViewCreate.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "json": `{"name":"Main"}`}, nil, nil)); err != nil {
t.Fatalf("create validate err=%v", err)
}
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err != nil {
t.Fatalf("invalid view json should bypass CLI validate, err=%v", err)
if err := BaseViewSetGroup.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseViewSetSort.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": `[{"field":"fld_1"}]`}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
if err := BaseViewSetTimebar.Validate(ctx, newBaseTestRuntime(map[string]string{"base-token": "b", "table-id": "tbl_1", "view-id": "Main", "json": "{"}, nil, nil)); err == nil || !strings.Contains(err.Error(), "--json invalid JSON object") {
t.Fatalf("err=%v", err)
}
}

View File

@@ -81,16 +81,7 @@ func dryRunFieldSearchOptions(_ context.Context, runtime *common.RuntimeContext)
func validateFieldJSON(runtime *common.RuntimeContext) (map[string]interface{}, error) {
pc := newParseCtx(runtime)
raw, _ := loadJSONInput(pc, runtime.Str("json"), "json")
if raw == "" {
return nil, nil
}
var body map[string]interface{}
_ = common.ParseJSON([]byte(raw), &body)
if body == nil {
return nil, nil
}
return body, nil
return parseJSONObject(pc, runtime.Str("json"), "json")
}
func validateFormulaLookupGuideAck(runtime *common.RuntimeContext, command string, body map[string]interface{}) error {

View File

@@ -6,6 +6,7 @@ package base
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
@@ -36,7 +37,14 @@ func parseJSONObject(pc *parseCtx, raw string, flagName string) (map[string]inte
}
var result map[string]interface{}
if err := common.ParseJSON([]byte(resolved), &result); err != nil {
return nil, formatJSONError(flagName, "object", err)
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
return nil, formatJSONError(flagName, "object", err)
}
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
}
if result == nil {
return nil, common.FlagErrorf("--%s must be a JSON object; %s", flagName, jsonInputTip(flagName))
}
return result, nil
}

View File

@@ -38,7 +38,10 @@ func TestParseHelpers(t *testing.T) {
if err != nil || obj["name"] != "demo" {
t.Fatalf("obj=%v err=%v", obj, err)
}
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "invalid JSON object") {
if _, err := parseJSONObject(testPC, `[1]`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") || !strings.Contains(err.Error(), "lark-base skill") || strings.Contains(err.Error(), "array") {
t.Fatalf("err=%v", err)
}
if _, err := parseJSONObject(testPC, `null`, "json"); err == nil || !strings.Contains(err.Error(), "--json must be a JSON object") {
t.Fatalf("err=%v", err)
}
obj, err = parseJSONObject(testPC, "@"+tmp.Name(), "json")
@@ -63,7 +66,7 @@ func TestParseHelpers(t *testing.T) {
if _, err := parseStringListFlexible(testPC, `[1]`, "fields"); err == nil || !strings.Contains(err.Error(), "invalid JSON string array") {
t.Fatalf("err=%v", err)
}
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a JSON object/array directly") {
if _, err := parseJSONValue(testPC, "{", "json"); err == nil || !strings.Contains(err.Error(), "tip: pass a valid JSON directly") || !strings.Contains(err.Error(), "@file.json") || !strings.Contains(err.Error(), "lark-base skill") {
t.Fatalf("err=%v", err)
}
if !reflect.DeepEqual(parseStringList("m,n"), []string{"m", "n"}) {
@@ -281,11 +284,11 @@ func TestJSONInputHelpers(t *testing.T) {
t.Fatalf("err=%v", err)
}
syntaxErr := formatJSONError("json", "object", &json.SyntaxError{Offset: 7})
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a JSON object/array directly") {
if !strings.Contains(syntaxErr.Error(), "near byte 7") || !strings.Contains(syntaxErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(syntaxErr.Error(), "@file.json") || !strings.Contains(syntaxErr.Error(), "lark-base skill") {
t.Fatalf("syntaxErr=%v", syntaxErr)
}
typeErr := formatJSONError("json", "object", &json.UnmarshalTypeError{Field: "filter_info"})
if !strings.Contains(typeErr.Error(), `field "filter_info"`) {
if !strings.Contains(typeErr.Error(), `field "filter_info"`) || !strings.Contains(typeErr.Error(), "tip: pass a valid JSON directly") || !strings.Contains(typeErr.Error(), "@file.json") || !strings.Contains(typeErr.Error(), "lark-base skill") {
t.Fatalf("typeErr=%v", typeErr)
}
}

View File

@@ -25,6 +25,9 @@ var BaseRecordBatchCreate = common.Shortcut{
`Example: --json '{"fields":["Title","Status"],"rows":[["Task A","Open"],["Task B","Done"]]}'`,
"Agent hint: use the lark-base skill's record-batch-create guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordBatchCreate,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordBatchCreate(runtime)

View File

@@ -25,6 +25,9 @@ var BaseRecordBatchUpdate = common.Shortcut{
`Example: --json '{"record_id_list":["recXXX"],"patch":{"Status":"Done"}}'`,
"Agent hint: use the lark-base skill's record-batch-update guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordBatchUpdate,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordBatchUpdate(runtime)

View File

@@ -113,7 +113,9 @@ func dryRunRecordHistoryList(_ context.Context, runtime *common.RuntimeContext)
}
func validateRecordJSON(runtime *common.RuntimeContext) error {
return nil
pc := newParseCtx(runtime)
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
return err
}
func recordListFields(runtime *common.RuntimeContext) []string {

View File

@@ -25,6 +25,9 @@ var BaseRecordSearch = common.Shortcut{
`Example: --json '{"keyword":"Alice","search_fields":["Name"]}'`,
"Agent hint: use the lark-base skill's record-search guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordSearch,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordSearch(runtime)

View File

@@ -5,15 +5,11 @@ package base
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"path/filepath"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/extension/fileio"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/util"
@@ -21,8 +17,8 @@ import (
)
const (
baseAttachmentUploadMaxFileSize = 20 * 1024 * 1024
baseAttachmentParentType = "bitable_file"
baseAttachmentUploadMaxFileSize int64 = 2 * 1024 * 1024 * 1024
baseAttachmentParentType = "bitable_file"
)
var BaseRecordUploadAttachment = common.Shortcut{
@@ -37,7 +33,7 @@ var BaseRecordUploadAttachment = common.Shortcut{
tableRefFlag(true),
recordRefFlag(true),
fieldRefFlag(true),
{Name: "file", Desc: "local file path (max 20MB)", Required: true},
{Name: "file", Desc: "local file path (max 2GB; files > 20MB use multipart upload automatically)", Required: true},
{Name: "name", Desc: "attachment file name (default: local file name)"},
},
DryRun: dryRunRecordUploadAttachment,
@@ -52,7 +48,7 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
if fileName == "" {
fileName = filepath.Base(filePath)
}
return common.NewDryRunAPI().
dry := common.NewDryRunAPI().
Desc("4-step orchestration: validate attachment field → read existing record attachments → upload file to Base → patch merged attachment array").
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/fields/:field_id").
Desc("[1] Read target field and ensure it is an attachment field").
@@ -61,15 +57,42 @@ func dryRunRecordUploadAttachment(_ context.Context, runtime *common.RuntimeCont
Set("field_id", runtime.Str("field-id")).
GET("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
Desc("[2] Read current record to preserve existing attachments in the target cell").
Set("record_id", runtime.Str("record-id")).
POST("/open-apis/drive/v1/medias/upload_all").
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": baseAttachmentParentType,
"parent_node": runtime.Str("base-token"),
"file": "@" + filePath,
}).
Set("record_id", runtime.Str("record-id"))
if baseAttachmentShouldUseMultipart(runtime.FileIO(), filePath) {
dry.POST("/open-apis/drive/v1/medias/upload_prepare").
Desc("[3a] Initialize multipart attachment upload to the current Base").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": baseAttachmentParentType,
"parent_node": runtime.Str("base-token"),
"size": "<file_size>",
}).
POST("/open-apis/drive/v1/medias/upload_part").
Desc("[3b] Upload attachment parts (repeated)").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"seq": "<chunk_index>",
"size": "<chunk_size>",
"file": "<chunk_binary>",
}).
POST("/open-apis/drive/v1/medias/upload_finish").
Desc("[3c] Finalize multipart attachment upload and get file token").
Body(map[string]interface{}{
"upload_id": "<upload_id>",
"block_num": "<block_num>",
})
} else {
dry.POST("/open-apis/drive/v1/medias/upload_all").
Desc("[3] Upload local file to the current Base as attachment media (multipart/form-data)").
Body(map[string]interface{}{
"file_name": fileName,
"parent_type": baseAttachmentParentType,
"parent_node": runtime.Str("base-token"),
"file": "@" + filePath,
"size": "<file_size>",
})
}
return dry.
PATCH("/open-apis/base/v3/bases/:base_token/tables/:table_id/records/:record_id").
Desc("[4] Update the target attachment cell with existing attachments plus the uploaded file token").
Body(map[string]interface{}{
@@ -102,7 +125,7 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
return output.ErrValidation("file not accessible: %s: %v", filePath, err)
}
if fileInfo.Size() > baseAttachmentUploadMaxFileSize {
return output.ErrValidation("file %.1fMB exceeds 20MB limit", float64(fileInfo.Size())/1024/1024)
return output.ErrValidation("file %s exceeds 2GB limit", common.FormatSize(fileInfo.Size()))
}
fileName := strings.TrimSpace(runtime.Str("name"))
@@ -124,6 +147,9 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
}
fmt.Fprintf(runtime.IO().ErrOut, "Uploading attachment: %s -> record %s field %s\n", fileName, runtime.Str("record-id"), fieldName(field))
if fileInfo.Size() > common.MaxDriveMediaUploadSinglePartSize {
fmt.Fprintf(runtime.IO().ErrOut, "File exceeds 20MB, using multipart upload\n")
}
attachment, err := uploadAttachmentToBase(runtime, filePath, fileName, runtime.Str("base-token"), fileInfo.Size())
if err != nil {
@@ -151,6 +177,14 @@ func executeRecordUploadAttachment(runtime *common.RuntimeContext) error {
return nil
}
func baseAttachmentShouldUseMultipart(fio fileio.FileIO, filePath string) bool {
info, err := fio.Stat(filePath)
if err != nil {
return false
}
return info.Mode().IsRegular() && info.Size() > common.MaxDriveMediaUploadSinglePartSize
}
func fetchBaseField(runtime *common.RuntimeContext, baseToken, tableIDValue, fieldRef string) (map[string]interface{}, error) {
return baseV3Call(runtime, "GET", baseV3Path("bases", baseToken, "tables", tableIDValue, "fields", fieldRef), nil, nil)
}
@@ -209,47 +243,30 @@ func normalizeAttachmentForPatch(attachment map[string]interface{}) map[string]i
}
func uploadAttachmentToBase(runtime *common.RuntimeContext, filePath, fileName, baseToken string, fileSize int64) (map[string]interface{}, error) {
f, err := runtime.FileIO().Open(filePath)
parentNode := baseToken
var (
fileToken string
err error
)
if fileSize <= common.MaxDriveMediaUploadSinglePartSize {
fileToken, err = common.UploadDriveMediaAll(runtime, common.DriveMediaUploadAllConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: baseAttachmentParentType,
ParentNode: &parentNode,
})
} else {
fileToken, err = common.UploadDriveMediaMultipart(runtime, common.DriveMediaMultipartUploadConfig{
FilePath: filePath,
FileName: fileName,
FileSize: fileSize,
ParentType: baseAttachmentParentType,
ParentNode: parentNode,
})
}
if err != nil {
return nil, output.ErrValidation("cannot open file: %v", err)
}
defer f.Close()
fd := larkcore.NewFormdata()
fd.AddField("file_name", fileName)
fd.AddField("parent_type", baseAttachmentParentType)
fd.AddField("parent_node", baseToken)
fd.AddField("size", fmt.Sprintf("%d", fileSize))
fd.AddFile("file", f)
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/drive/v1/medias/upload_all",
Body: fd,
}, larkcore.WithFileUpload())
if err != nil {
var exitErr *output.ExitError
if errors.As(err, &exitErr) {
return nil, err
}
return nil, output.ErrNetwork("upload failed: %v", err)
}
var result map[string]interface{}
if err := json.Unmarshal(apiResp.RawBody, &result); err != nil {
return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: invalid response JSON: %v", err)
}
code, _ := util.ToFloat64(result["code"])
if code != 0 {
msg, _ := result["msg"].(string)
return nil, output.ErrAPI(int(code), fmt.Sprintf("upload failed: [%d] %s", int(code), msg), result["error"])
}
data, _ := result["data"].(map[string]interface{})
fileToken, _ := data["file_token"].(string)
if fileToken == "" {
return nil, output.Errorf(output.ExitAPI, "api_error", "upload failed: no file_token returned")
return nil, err
}
attachment := map[string]interface{}{

View File

@@ -26,6 +26,9 @@ var BaseRecordUpsert = common.Shortcut{
`Example: --json '{"Name":"Alice"}'`,
"Agent hint: use the lark-base skill's record-upsert guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateRecordJSON(runtime)
},
DryRun: dryRunRecordUpsert,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeRecordUpsert(runtime)

View File

@@ -138,15 +138,15 @@ func wrapViewPropertyBody(raw interface{}, key string) interface{} {
}
func validateViewCreate(runtime *common.RuntimeContext) error {
return nil
pc := newParseCtx(runtime)
_, err := parseObjectList(pc, runtime.Str("json"), "json")
return err
}
func validateViewJSONObject(runtime *common.RuntimeContext) error {
return nil
}
func validateViewJSONValue(runtime *common.RuntimeContext) error {
return nil
pc := newParseCtx(runtime)
_, err := parseJSONObject(pc, runtime.Str("json"), "json")
return err
}
func executeViewList(runtime *common.RuntimeContext) error {

View File

@@ -27,7 +27,7 @@ var BaseViewSetGroup = common.Shortcut{
"Agent hint: use the lark-base skill's view-set-group guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONValue(runtime)
return validateViewJSONObject(runtime)
},
DryRun: dryRunViewSetGroup,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -27,7 +27,7 @@ var BaseViewSetSort = common.Shortcut{
"Agent hint: use the lark-base skill's view-set-sort guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONValue(runtime)
return validateViewJSONObject(runtime)
},
DryRun: dryRunViewSetSort,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {

View File

@@ -26,6 +26,9 @@ var BaseViewSetVisibleFields = common.Shortcut{
`Example: --json '{"visible_fields":["fldXXX"]}'`,
"Agent hint: use the lark-base skill's view-set-visible-fields guide for usage and limits.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateViewJSONObject(runtime)
},
DryRun: dryRunViewSetVisibleFields,
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
return executeViewSetVisibleFields(runtime)

View File

@@ -120,6 +120,8 @@ func permissionTargetLabel(resourceType string) string {
return "spreadsheet"
case "bitable", "base":
return "base"
case "slides":
return "presentation"
case "file":
return "file"
case "folder":

View File

@@ -42,6 +42,7 @@ type RuntimeContext struct {
resolvedAs core.Identity // effective identity resolved by framework
Factory *cmdutil.Factory // injected by framework
apiClientFunc func() (*client.APIClient, error) // sync.OnceValues; initialized in newRuntimeContext
botInfoFunc func() (*BotInfo, error) // sync.OnceValues; lazy bot identity from /bot/v3/info
larkSDK *lark.Client // eagerly initialized in mountDeclarative
}
@@ -71,6 +72,57 @@ func (ctx *RuntimeContext) IsBot() bool {
// UserOpenId returns the current user's open_id from config.
func (ctx *RuntimeContext) UserOpenId() string { return ctx.Config.UserOpenId }
// BotInfo holds bot identity metadata fetched lazily from /bot/v3/info.
type BotInfo struct {
OpenID string
AppName string
}
// BotInfo returns the bot's open_id and display name, fetched lazily from /bot/v3/info.
// Unlike UserOpenId() (which reads from config), this requires a network call and may fail.
// Thread-safe via sync.OnceValues; the API is called at most once per RuntimeContext.
func (ctx *RuntimeContext) BotInfo() (*BotInfo, error) {
if ctx.botInfoFunc == nil {
return nil, fmt.Errorf("BotInfo not available (runtime context not fully initialized)")
}
return ctx.botInfoFunc()
}
// fetchBotInfo calls /bot/v3/info using bot identity and parses the response.
func (ctx *RuntimeContext) fetchBotInfo() (*BotInfo, error) {
if !ctx.Config.CanBot() {
return nil, fmt.Errorf("fetch bot info: bot identity is not available in current credential context")
}
resp, err := ctx.DoAPIAsBot(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/bot/v3/info",
})
if err != nil {
return nil, fmt.Errorf("fetch bot info: %w", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("fetch bot info: HTTP %d", resp.StatusCode)
}
var envelope struct {
Code int `json:"code"`
Msg string `json:"msg"`
Data struct {
OpenID string `json:"open_id"`
AppName string `json:"app_name"`
} `json:"data"`
}
if err := json.Unmarshal(resp.RawBody, &envelope); err != nil {
return nil, fmt.Errorf("fetch bot info: unmarshal: %w", err)
}
if envelope.Code != 0 {
return nil, fmt.Errorf("fetch bot info: [%d] %s", envelope.Code, envelope.Msg)
}
if envelope.Data.OpenID == "" {
return nil, fmt.Errorf("fetch bot info: open_id is empty")
}
return &BotInfo{OpenID: envelope.Data.OpenID, AppName: envelope.Data.AppName}, nil
}
// Ctx returns the context.Context propagated from cmd.Context().
func (ctx *RuntimeContext) Ctx() context.Context { return ctx.ctx }
@@ -639,6 +691,7 @@ func newRuntimeContext(cmd *cobra.Command, f *cmdutil.Factory, s *Shortcut, conf
rctx.apiClientFunc = sync.OnceValues(func() (*client.APIClient, error) {
return f.NewAPIClientWithConfig(config)
})
rctx.botInfoFunc = sync.OnceValues(rctx.fetchBotInfo)
sdk, err := f.LarkClient()
if err != nil {

View File

@@ -0,0 +1,297 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"context"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
)
// botInfoTestConfig returns a CliConfig suitable for bot info tests.
func botInfoTestConfig(t *testing.T) *core.CliConfig {
t.Helper()
return &core.CliConfig{
AppID: "test-app",
AppSecret: "test-secret",
Brand: core.BrandFeishu,
}
}
// runBotInfoShortcut mounts a shortcut that calls BotInfo() and executes it.
// The shortcut stores the result (or error) in the provided pointers.
func runBotInfoShortcut(t *testing.T, f *cmdutil.Factory, gotInfo **BotInfo, gotErr *error) {
t.Helper()
s := Shortcut{
Service: "test",
Command: "+bot-info",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *RuntimeContext) error {
info, err := rctx.BotInfo()
*gotInfo = info
*gotErr = err
return nil
},
}
parent := &cobra.Command{Use: "test"}
s.Mount(parent, f)
parent.SetArgs([]string{"+bot-info", "--as", "bot"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("shortcut execution failed: %v", err)
}
}
func TestFetchBotInfo_Success(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"open_id": "ou_bot_abc123",
"app_name": "TestBot",
},
},
})
var info *BotInfo
var err error
runBotInfoShortcut(t, f, &info, &err)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if info.OpenID != "ou_bot_abc123" {
t.Errorf("OpenID = %q, want %q", info.OpenID, "ou_bot_abc123")
}
if info.AppName != "TestBot" {
t.Errorf("AppName = %q, want %q", info.AppName, "TestBot")
}
}
func TestFetchBotInfo_ShortcutHeaders(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"open_id": "ou_bot_header",
"app_name": "HeaderBot",
},
},
}
reg.Register(stub)
var info *BotInfo
var err error
runBotInfoShortcut(t, f, &info, &err)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Verify shortcut context headers were injected
if stub.CapturedHeaders.Get("X-Cli-Shortcut") == "" {
t.Error("missing X-Cli-Shortcut header on /bot/v3/info request")
}
if stub.CapturedHeaders.Get("X-Cli-Execution-Id") == "" {
t.Error("missing X-Cli-Execution-Id header on /bot/v3/info request")
}
}
func TestFetchBotInfo_OnceSemantics(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
// Only register one stub — if fetchBotInfo is called twice, the second call
// would fail with "no stub" since the first stub is already matched.
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"open_id": "ou_bot_once",
"app_name": "OnceBot",
},
},
})
s := Shortcut{
Service: "test",
Command: "+bot-info-once",
AuthTypes: []string{"bot"},
Execute: func(_ context.Context, rctx *RuntimeContext) error {
// Call BotInfo twice — second should use cached result
_, _ = rctx.BotInfo()
info, err := rctx.BotInfo()
if err != nil {
t.Errorf("second BotInfo() call failed: %v", err)
}
if info.OpenID != "ou_bot_once" {
t.Errorf("OpenID = %q, want %q", info.OpenID, "ou_bot_once")
}
return nil
},
}
parent := &cobra.Command{Use: "test"}
s.Mount(parent, f)
parent.SetArgs([]string{"+bot-info-once", "--as", "bot"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("shortcut execution failed: %v", err)
}
}
func TestFetchBotInfo_APICodeNonZero(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 99991,
"msg": "no permission",
},
})
var info *BotInfo
var err error
runBotInfoShortcut(t, f, &info, &err)
if err == nil {
t.Fatal("expected error for non-zero code")
}
if !strings.Contains(err.Error(), "[99991]") {
t.Errorf("error = %q, want substring [99991]", err.Error())
}
}
func TestFetchBotInfo_EmptyOpenID(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"open_id": "",
"app_name": "EmptyBot",
},
},
})
var info *BotInfo
var err error
runBotInfoShortcut(t, f, &info, &err)
if err == nil {
t.Fatal("expected error for empty open_id")
}
if !strings.Contains(err.Error(), "open_id is empty") {
t.Errorf("error = %q, want substring 'open_id is empty'", err.Error())
}
}
func TestFetchBotInfo_HTTP4xx(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
Status: 403,
Body: map[string]interface{}{"code": 403, "msg": "forbidden"},
})
var info *BotInfo
var err error
runBotInfoShortcut(t, f, &info, &err)
if err == nil {
t.Fatal("expected error for HTTP 403")
}
if !strings.Contains(err.Error(), "403") {
t.Errorf("error = %q, want substring '403'", err.Error())
}
}
func TestFetchBotInfo_InvalidJSON(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, botInfoTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/bot/v3/info",
RawBody: []byte("not json"),
})
var info *BotInfo
var err error
runBotInfoShortcut(t, f, &info, &err)
if err == nil {
t.Fatal("expected error for invalid JSON")
}
// Error may come from SDK-level parse or our unmarshal wrapper
if !strings.Contains(err.Error(), "unmarshal") && !strings.Contains(err.Error(), "invalid character") {
t.Errorf("error = %q, want JSON parse failure", err.Error())
}
}
func TestFetchBotInfo_CanBotFalse(t *testing.T) {
cfg := botInfoTestConfig(t)
cfg.SupportedIdentities = 1 // user only
f, _, _, _ := cmdutil.TestFactory(t, cfg)
// Use a dual-auth shortcut running as user, calling BotInfo() internally.
// No /bot/v3/info stub — CanBot should short-circuit before API call.
var info *BotInfo
var err error
s := Shortcut{
Service: "test",
Command: "+bot-info-canbot",
AuthTypes: []string{"user", "bot"},
Execute: func(_ context.Context, rctx *RuntimeContext) error {
i, e := rctx.BotInfo()
info = i
err = e
return nil
},
}
parent := &cobra.Command{Use: "test"}
s.Mount(parent, f)
parent.SetArgs([]string{"+bot-info-canbot", "--as", "user"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if execErr := parent.Execute(); execErr != nil {
t.Fatalf("shortcut execution failed: %v", execErr)
}
if err == nil {
t.Fatal("expected error when bot identity not available")
}
if info != nil {
t.Errorf("expected nil info, got %+v", info)
}
if !strings.Contains(err.Error(), "not available") {
t.Errorf("error = %q, want substring 'not available'", err.Error())
}
}
func TestBotInfo_NilFunc(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
rctx := TestNewRuntimeContext(cmd, &core.CliConfig{})
_, err := rctx.BotInfo()
if err == nil {
t.Fatal("expected error for nil botInfoFunc")
}
if !strings.Contains(err.Error(), "not fully initialized") {
t.Errorf("unexpected error: %v", err)
}
}

View File

@@ -5,6 +5,7 @@ package common
import (
"context"
"sync"
"github.com/spf13/cobra"
@@ -27,3 +28,12 @@ func TestNewRuntimeContextWithCtx(ctx context.Context, cmd *cobra.Command, cfg *
func TestNewRuntimeContextWithIdentity(cmd *cobra.Command, cfg *core.CliConfig, as core.Identity) *RuntimeContext {
return &RuntimeContext{Cmd: cmd, Config: cfg, resolvedAs: as}
}
// TestNewRuntimeContextWithBotInfo creates a RuntimeContext with a pre-set BotInfo for testing.
func TestNewRuntimeContextWithBotInfo(cmd *cobra.Command, cfg *core.CliConfig, info *BotInfo) *RuntimeContext {
rctx := &RuntimeContext{Cmd: cmd, Config: cfg}
rctx.botInfoFunc = sync.OnceValues(func() (*BotInfo, error) {
return info, nil
})
return rctx
}

View File

@@ -0,0 +1,136 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveCreateShortcutAllowedTypes = map[string]bool{
"file": true,
"docx": true,
"bitable": true,
"doc": true,
"sheet": true,
"mindnote": true,
"slides": true,
}
type driveCreateShortcutSpec struct {
FileToken string
FileType string
FolderToken string
}
func newDriveCreateShortcutSpec(runtime *common.RuntimeContext) driveCreateShortcutSpec {
return driveCreateShortcutSpec{
FileToken: strings.TrimSpace(runtime.Str("file-token")),
FileType: strings.ToLower(strings.TrimSpace(runtime.Str("type"))),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
}
}
// RequestBody builds the create_shortcut API payload from the shortcut spec.
func (s driveCreateShortcutSpec) RequestBody() map[string]interface{} {
return map[string]interface{}{
"parent_token": s.FolderToken,
"refer_entity": map[string]interface{}{
"refer_token": s.FileToken,
"refer_type": s.FileType,
},
}
}
// DriveCreateShortcut creates a Drive shortcut for an existing file in another folder.
var DriveCreateShortcut = common.Shortcut{
Service: "drive",
Command: "+create-shortcut",
Description: "Create a Drive shortcut in another folder",
Risk: "write",
Scopes: []string{"space:document:shortcut"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "source file token to reference", Required: true},
{Name: "type", Desc: "source file type (file, docx, bitable, doc, sheet, mindnote, slides)", Required: true},
{Name: "folder-token", Desc: "target folder token for the new shortcut", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveCreateShortcutSpec(newDriveCreateShortcutSpec(runtime))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := newDriveCreateShortcutSpec(runtime)
return common.NewDryRunAPI().
Desc("Create a Drive shortcut").
POST("/open-apis/drive/v1/files/create_shortcut").
Desc("[1] Create shortcut").
Body(spec.RequestBody())
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := newDriveCreateShortcutSpec(runtime)
fmt.Fprintf(
runtime.IO().ErrOut,
"Creating shortcut for %s %s in folder %s...\n",
spec.FileType,
common.MaskToken(spec.FileToken),
common.MaskToken(spec.FolderToken),
)
data, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/files/create_shortcut",
nil,
spec.RequestBody(),
)
if err != nil {
return err
}
out := map[string]interface{}{
"created": true,
"source_file_token": spec.FileToken,
"source_type": spec.FileType,
"folder_token": spec.FolderToken,
}
if shortcutToken := common.GetString(data, "succ_shortcut_node", "token"); shortcutToken != "" {
out["shortcut_token"] = shortcutToken
}
if url := common.GetString(data, "succ_shortcut_node", "url"); url != "" {
out["url"] = url
}
if title := common.GetString(data, "succ_shortcut_node", "name"); title != "" {
out["title"] = title
}
runtime.Out(out, nil)
return nil
},
}
// validateDriveCreateShortcutSpec validates shortcut creation inputs before API execution.
func validateDriveCreateShortcutSpec(spec driveCreateShortcutSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
if err := validate.ResourceName(spec.FolderToken, "--folder-token"); err != nil {
return output.ErrValidation("%s", err)
}
if spec.FileType == "wiki" {
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive file tokens; wiki documents must be resolved to their underlying file token first")
}
if spec.FileType == "folder" {
return output.ErrValidation("unsupported file type: folder. The create_shortcut API only supports Drive files, not folders")
}
if !driveCreateShortcutAllowedTypes[spec.FileType] {
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, slides", spec.FileType)
}
return nil
}

View File

@@ -0,0 +1,336 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
// TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes verifies unsupported source types are rejected early.
func TestValidateDriveCreateShortcutSpecRejectsUnsupportedTypes(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spec driveCreateShortcutSpec
wantErr string
}{
{
name: "wiki",
spec: driveCreateShortcutSpec{
FileToken: "wiki_token_test",
FileType: "wiki",
FolderToken: "target_folder_token_test",
},
wantErr: "underlying file token first",
},
{
name: "folder",
spec: driveCreateShortcutSpec{
FileToken: "folder_token_test",
FileType: "folder",
FolderToken: "target_folder_token_test",
},
wantErr: "not folders",
},
{
name: "shortcut",
spec: driveCreateShortcutSpec{
FileToken: "shortcut_token_test",
FileType: "shortcut",
FolderToken: "target_folder_token_test",
},
wantErr: "Supported types",
},
{
name: "missing folder token",
spec: driveCreateShortcutSpec{
FileToken: "file_token_test",
FileType: "docx",
},
wantErr: "--folder-token must not be empty",
},
{
name: "unknown",
spec: driveCreateShortcutSpec{
FileToken: "file_token_test",
FileType: "unknown",
FolderToken: "target_folder_token_test",
},
wantErr: "Supported types",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateDriveCreateShortcutSpec(tt.spec)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
}
})
}
}
// TestDriveCreateShortcutDryRunIncludesSingleCreateRequest verifies dry-run only previews the create request.
func TestDriveCreateShortcutDryRunIncludesSingleCreateRequest(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +create-shortcut"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
if err := cmd.Flags().Set("file-token", " doc_token_test "); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
t.Fatalf("set --type: %v", err)
}
if err := cmd.Flags().Set("folder-token", " folder_target_token_test "); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := DriveCreateShortcut.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 {
Method string `json:"method"`
Body map[string]interface{} `json:"body"`
} `json:"api"`
}
if err := json.Unmarshal(data, &got); err != nil {
t.Fatalf("unmarshal dry run json: %v", err)
}
if len(got.API) != 1 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
}
if got.API[0].Method != "POST" {
t.Fatalf("first method = %q, want POST", got.API[0].Method)
}
if got.API[0].Body["parent_token"] != "folder_target_token_test" {
t.Fatalf("parent_token = %#v, want folder_target_token_test", got.API[0].Body["parent_token"])
}
referEntity, _ := got.API[0].Body["refer_entity"].(map[string]interface{})
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
t.Fatalf("unexpected refer_entity: %#v", referEntity)
}
}
// TestDriveCreateShortcutUsesProvidedFolderToken verifies execution uses the explicit target folder token.
func TestDriveCreateShortcutUsesProvidedFolderToken(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
createStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_shortcut",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"succ_shortcut_node": map[string]interface{}{
"token": "shortcut_token_test",
"name": "shortcut_name_test",
"type": "docx",
"parent_token": "folder_target_token_test",
"url": "https://example.feishu.cn/docx/shortcut_token_test",
"shortcut_info": map[string]interface{}{
"target_type": "docx",
"target_token": "doc_token_test",
},
},
},
},
}
reg.Register(createStub)
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
"+create-shortcut",
"--file-token", " doc_token_test ",
"--type", " DOCX ",
"--folder-token", " folder_target_token_test ",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
body := decodeCapturedJSONBody(t, createStub)
if body["parent_token"] != "folder_target_token_test" {
t.Fatalf("parent_token = %#v, want folder_target_token_test", body["parent_token"])
}
referEntity, _ := body["refer_entity"].(map[string]interface{})
if referEntity["refer_token"] != "doc_token_test" || referEntity["refer_type"] != "docx" {
t.Fatalf("unexpected refer_entity: %#v", referEntity)
}
data := decodeDriveEnvelope(t, stdout)
if data["shortcut_token"] != "shortcut_token_test" {
t.Fatalf("shortcut_token = %#v, want shortcut_token_test", data["shortcut_token"])
}
if data["folder_token"] != "folder_target_token_test" {
t.Fatalf("folder_token = %#v, want folder_target_token_test", data["folder_token"])
}
if data["source_file_token"] != "doc_token_test" {
t.Fatalf("source_file_token = %#v, want doc_token_test", data["source_file_token"])
}
if data["title"] != "shortcut_name_test" {
t.Fatalf("title = %#v, want shortcut_name_test", data["title"])
}
if data["url"] != "https://example.feishu.cn/docx/shortcut_token_test" {
t.Fatalf("url = %#v, want https://example.feishu.cn/docx/shortcut_token_test", data["url"])
}
if data["created"] != true {
t.Fatalf("created = %#v, want true", data["created"])
}
}
// TestDriveCreateShortcutValidateRequiresFolderToken verifies folder-token is mandatory.
func TestDriveCreateShortcutValidateRequiresFolderToken(t *testing.T) {
err := validateDriveCreateShortcutSpec(driveCreateShortcutSpec{
FileToken: "doc_token_test",
FileType: "docx",
})
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken verifies runtime normalization rejects blank folder tokens.
func TestDriveCreateShortcutValidateRejectsWhitespaceOnlyFolderToken(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +create-shortcut"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
cmd.Flags().String("folder-token", "", "")
if err := cmd.Flags().Set("file-token", "doc_token_test"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", " DOCX "); err != nil {
t.Fatalf("set --type: %v", err)
}
if err := cmd.Flags().Set("folder-token", " "); err != nil {
t.Fatalf("set --folder-token: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
err := DriveCreateShortcut.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if !strings.Contains(err.Error(), "--folder-token must not be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestDriveCreateShortcutClassifiesKnownAPIConstraints verifies known API constraints surface as structured errors.
func TestDriveCreateShortcutClassifiesKnownAPIConstraints(t *testing.T) {
t.Parallel()
tests := []struct {
name string
code int
msg string
wantType string
wantHint string
wantMsgPart string
}{
{
name: "resource contention",
code: output.LarkErrDriveResourceContention,
msg: "resource contention occurred, please retry",
wantType: "conflict",
wantHint: "avoid concurrent duplicate requests",
wantMsgPart: "resource contention occurred",
},
{
name: "cross tenant and unit",
code: output.LarkErrDriveCrossTenantUnit,
msg: "cross tenant and unit not support",
wantType: "cross_tenant_unit",
wantHint: "same tenant and region/unit",
wantMsgPart: "cross tenant and unit not support",
},
{
name: "cross brand",
code: output.LarkErrDriveCrossBrand,
msg: "cross brand not support",
wantType: "cross_brand",
wantHint: "same brand environment",
wantMsgPart: "cross brand not support",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, _, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/create_shortcut",
Body: map[string]interface{}{
"code": float64(tt.code),
"msg": tt.msg,
},
})
err := mountAndRunDrive(t, DriveCreateShortcut, []string{
"+create-shortcut",
"--file-token", "doc_token_test",
"--type", "docx",
"--folder-token", "folder_token_test",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %v", err)
}
if exitErr.Code != output.ExitAPI {
t.Fatalf("exit code = %d, want %d", exitErr.Code, output.ExitAPI)
}
if exitErr.Detail.Type != tt.wantType {
t.Fatalf("type = %q, want %q", exitErr.Detail.Type, tt.wantType)
}
if exitErr.Detail.Code != tt.code {
t.Fatalf("detail code = %d, want %d", exitErr.Detail.Code, tt.code)
}
if !strings.Contains(exitErr.Detail.Message, tt.wantMsgPart) {
t.Fatalf("message = %q, want substring %q", exitErr.Detail.Message, tt.wantMsgPart)
}
if !strings.Contains(exitErr.Detail.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", exitErr.Detail.Hint, tt.wantHint)
}
})
}
}

View File

@@ -0,0 +1,148 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"context"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var driveDeleteAllowedTypes = map[string]bool{
"file": true,
"docx": true,
"bitable": true,
"doc": true,
"sheet": true,
"mindnote": true,
"folder": true,
"shortcut": true,
"slides": true,
}
// driveDeleteSpec contains the normalized input needed to issue a delete
// request against the Drive files endpoint.
type driveDeleteSpec struct {
FileToken string
FileType string
}
// DriveDelete deletes a Drive file or folder and handles the async task
// polling required by folder deletes.
var DriveDelete = common.Shortcut{
Service: "drive",
Command: "+delete",
Description: "Delete a file or folder in Drive",
Risk: "high-risk-write",
Scopes: []string{"space:document:delete"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "file-token", Desc: "file or folder token to delete", Required: true},
{Name: "type", Desc: "file type (file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateDriveDeleteSpec(driveDeleteSpec{
FileToken: runtime.Str("file-token"),
FileType: strings.ToLower(runtime.Str("type")),
})
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := driveDeleteSpec{
FileToken: runtime.Str("file-token"),
FileType: strings.ToLower(runtime.Str("type")),
}
dry := common.NewDryRunAPI().
Desc("Delete file or folder in Drive")
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})
if spec.FileType == "folder" {
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[2] Poll async task status (for folder delete)").
Params(driveTaskCheckParams("<task_id>"))
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := driveDeleteSpec{
FileToken: runtime.Str("file-token"),
FileType: strings.ToLower(runtime.Str("type")),
}
fmt.Fprintf(runtime.IO().ErrOut, "Deleting %s %s...\n", spec.FileType, common.MaskToken(spec.FileToken))
data, err := runtime.CallAPI(
"DELETE",
fmt.Sprintf("/open-apis/drive/v1/files/%s", validate.EncodePathSegment(spec.FileToken)),
map[string]interface{}{"type": spec.FileType},
nil,
)
if err != nil {
return err
}
if spec.FileType == "folder" {
taskID := common.GetString(data, "task_id")
if taskID == "" {
return output.Errorf(output.ExitAPI, "api_error", "delete folder returned no task_id")
}
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete is async, polling task %s...\n", taskID)
status, ready, err := pollDriveTaskCheck(runtime, taskID)
if err != nil {
return err
}
out := map[string]interface{}{
"task_id": taskID,
"status": status.StatusLabel(),
"file_token": spec.FileToken,
"type": spec.FileType,
"ready": ready,
}
if ready {
out["deleted"] = true
}
if !ready {
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
fmt.Fprintf(runtime.IO().ErrOut, "Folder delete task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
runtime.Out(out, nil)
return nil
}
runtime.Out(map[string]interface{}{
"deleted": true,
"file_token": spec.FileToken,
"type": spec.FileType,
}, nil)
return nil
},
}
func validateDriveDeleteSpec(spec driveDeleteSpec) error {
if err := validate.ResourceName(spec.FileToken, "--file-token"); err != nil {
return output.ErrValidation("%s", err)
}
if spec.FileType == "wiki" {
return output.ErrValidation("unsupported file type: wiki. This shortcut only supports Drive files and folders; wiki documents are not supported")
}
if !driveDeleteAllowedTypes[spec.FileType] {
return output.ErrValidation("unsupported file type: %s. Supported types: file, docx, bitable, doc, sheet, mindnote, folder, shortcut, slides", spec.FileType)
}
return nil
}

View File

@@ -0,0 +1,224 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package drive
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestValidateDriveDeleteSpecRejectsWiki(t *testing.T) {
t.Parallel()
err := validateDriveDeleteSpec(driveDeleteSpec{
FileToken: "wiki_token_test",
FileType: "wiki",
})
if err == nil {
t.Fatal("expected wiki type error, got nil")
}
if !strings.Contains(err.Error(), "wiki documents are not supported") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDriveDeleteDryRunFolderIncludesTaskCheckParams(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "drive +delete"}
cmd.Flags().String("file-token", "", "")
cmd.Flags().String("type", "", "")
if err := cmd.Flags().Set("file-token", "fld_src"); err != nil {
t.Fatalf("set --file-token: %v", err)
}
if err := cmd.Flags().Set("type", "folder"); err != nil {
t.Fatalf("set --type: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := DriveDelete.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 {
Method string `json:"method"`
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) != 2 {
t.Fatalf("expected 2 API calls, got %d", len(got.API))
}
if got.API[0].Method != "DELETE" {
t.Fatalf("first method = %q, want DELETE", got.API[0].Method)
}
if got.API[0].Params["type"] != "folder" {
t.Fatalf("delete params = %#v", got.API[0].Params)
}
if got.API[1].Params["task_id"] != "<task_id>" {
t.Fatalf("task check params = %#v", got.API[1].Params)
}
}
func TestDriveDeleteRequiresYes(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, driveTestConfig())
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", "file_token_test",
"--type", "file",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("expected confirmation error, got nil")
}
if !strings.Contains(err.Error(), "requires confirmation") {
t.Fatalf("unexpected error: %v", err)
}
}
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{}{},
},
})
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(`"deleted": true`)) {
t.Fatalf("stdout missing deleted=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 TestDriveDeleteFolderTaskCheckOutcomes(t *testing.T) {
tests := []struct {
name string
taskCheckBody map[string]interface{}
wantErrContains string
wantStdout []string
}{
{
name: "success",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
},
wantStdout: []string{
`"task_id": "task_123"`,
`"deleted": true`,
`"ready": true`,
},
},
{
name: "timeout",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "process"},
},
wantStdout: []string{
`"ready": false`,
`"timed_out": true`,
`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123 --as bot"`,
},
},
{
name: "failed",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "fail"},
},
wantErrContains: "folder task failed",
},
{
name: "task_check error",
taskCheckBody: map[string]interface{}{
"code": 1061001,
"msg": "internal error",
},
wantErrContains: "internal error",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/fld_src",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_123"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: tt.taskCheckBody,
})
withSingleDriveTaskCheckPoll(t)
err := mountAndRunDrive(t, DriveDelete, []string{
"+delete",
"--file-token", "fld_src",
"--type", "folder",
"--yes",
"--as", "bot",
}, f, stdout)
if tt.wantErrContains != "" {
if err == nil {
t.Fatal("expected delete failure, got nil")
}
if !strings.Contains(err.Error(), tt.wantErrContains) {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, needle := range tt.wantStdout {
if !bytes.Contains(stdout.Bytes(), []byte(needle)) {
t.Fatalf("stdout missing %q: %s", needle, stdout.String())
}
}
})
}
}

View File

@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
@@ -18,6 +19,8 @@ import (
"github.com/larksuite/cli/shortcuts/common"
)
var driveTaskCheckPollMu sync.Mutex
func driveTestConfig() *core.CliConfig {
return &core.CliConfig{
AppID: "drive-test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
@@ -37,6 +40,18 @@ func mountAndRunDrive(t *testing.T, s common.Shortcut, args []string, f *cmdutil
return parent.Execute()
}
func withSingleDriveTaskCheckPoll(t *testing.T) {
t.Helper()
driveTaskCheckPollMu.Lock()
prevAttempts, prevInterval := driveTaskCheckPollAttempts, driveTaskCheckPollInterval
driveTaskCheckPollAttempts, driveTaskCheckPollInterval = 1, 0
t.Cleanup(func() {
driveTaskCheckPollAttempts, driveTaskCheckPollInterval = prevAttempts, prevInterval
driveTaskCheckPollMu.Unlock()
})
}
func withDriveWorkingDir(t *testing.T, dir string) {
t.Helper()
cwd, err := os.Getwd()

View File

@@ -115,7 +115,7 @@ var DriveMove = common.Shortcut{
"ready": ready,
}
if !ready {
nextCommand := driveTaskCheckResultCommand(taskID)
nextCommand := driveTaskCheckResultCommand(taskID, string(runtime.As()))
fmt.Fprintf(runtime.IO().ErrOut, "Folder move task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand

View File

@@ -14,8 +14,8 @@ import (
)
var (
driveMovePollAttempts = 30
driveMovePollInterval = 2 * time.Second
driveTaskCheckPollAttempts = 30
driveTaskCheckPollInterval = 2 * time.Second
)
// driveMoveAllowedTypes mirrors the document kinds accepted by the Drive move
@@ -61,7 +61,7 @@ func validateDriveMoveSpec(spec driveMoveSpec) error {
}
// driveTaskCheckStatus represents the status payload returned by
// /drive/v1/files/task_check for async folder operations.
// /drive/v1/files/task_check for async folder move/delete operations.
type driveTaskCheckStatus struct {
TaskID string
Status string
@@ -72,7 +72,11 @@ func (s driveTaskCheckStatus) Ready() bool {
}
func (s driveTaskCheckStatus) Failed() bool {
return strings.EqualFold(strings.TrimSpace(s.Status), "failed")
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
// terminal state "fail".
return strings.EqualFold(status, "failed") || strings.EqualFold(status, "fail")
}
func (s driveTaskCheckStatus) Pending() bool {
@@ -91,8 +95,8 @@ func (s driveTaskCheckStatus) StatusLabel() string {
// driveTaskCheckResultCommand prints the resume command shown when bounded
// polling ends before the backend task completes.
func driveTaskCheckResultCommand(taskID string) string {
return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s", taskID)
func driveTaskCheckResultCommand(taskID, as string) string {
return fmt.Sprintf("lark-cli drive +task_result --scenario task_check --task-id %s --as %s", taskID, as)
}
// driveTaskCheckParams keeps the task_check query parameter shape in one place
@@ -130,31 +134,42 @@ func parseDriveTaskCheckStatus(taskID string, data map[string]interface{}) drive
}
}
// pollDriveTaskCheck polls the backend for a bounded period and returns the
// last seen status so callers can emit a follow-up command when needed.
// pollDriveTaskCheck polls the shared task_check endpoint for a bounded period
// and returns the last seen status so callers can emit a follow-up command
// when needed.
func pollDriveTaskCheck(runtime *common.RuntimeContext, taskID string) (driveTaskCheckStatus, bool, error) {
lastStatus := driveTaskCheckStatus{TaskID: taskID}
for attempt := 1; attempt <= driveMovePollAttempts; attempt++ {
var (
seenStatus bool
lastErr error
)
for attempt := 1; attempt <= driveTaskCheckPollAttempts; attempt++ {
if attempt > 1 {
time.Sleep(driveMovePollInterval)
time.Sleep(driveTaskCheckPollInterval)
}
status, err := getDriveTaskCheckStatus(runtime, taskID)
if err != nil {
lastErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Error polling task %s: %s\n", taskID, err)
continue
}
seenStatus = true
lastStatus = status
// 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 move completed successfully.\n")
fmt.Fprintf(runtime.IO().ErrOut, "Folder task completed successfully.\n")
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder move task failed")
return status, false, output.Errorf(output.ExitAPI, "api_error", "folder task failed")
}
}
if !seenStatus && lastErr != nil {
return driveTaskCheckStatus{}, false, lastErr
}
return lastStatus, false, nil
}

View File

@@ -102,91 +102,91 @@ func TestDriveMoveDryRunFolderIncludesTaskCheckParams(t *testing.T) {
}
}
func TestDriveMoveFolderSuccessUsesTaskCheckHelper(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/fld_src/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_123"},
func TestDriveMoveFolderTaskCheckOutcomes(t *testing.T) {
tests := []struct {
name string
taskCheckBody map[string]interface{}
wantErrContains string
wantStdout []string
}{
{
name: "success",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "success"},
},
wantStdout: []string{
`"task_id": "task_123"`,
`"ready": true`,
},
},
})
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"},
{
name: "timeout",
taskCheckBody: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"status": "pending"},
},
wantStdout: []string{
`"ready": false`,
`"timed_out": true`,
`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123 --as bot"`,
},
},
{
name: "all polls fail",
taskCheckBody: map[string]interface{}{
"code": 1061001,
"msg": "internal error",
},
wantErrContains: "internal error",
},
})
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
driveMovePollAttempts, driveMovePollInterval = 1, 0
t.Cleanup(func() {
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveMove, []string{
"+move",
"--file-token", "fld_src",
"--type", "folder",
"--folder-token", "fld_dst",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"task_id": "task_123"`)) {
t.Fatalf("stdout missing task id: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": true`)) {
t.Fatalf("stdout missing ready=true: %s", stdout.String())
}
}
func TestDriveMoveFolderTimeoutReturnsFollowUpCommand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/fld_src/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_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": "pending"},
},
})
prevAttempts, prevInterval := driveMovePollAttempts, driveMovePollInterval
driveMovePollAttempts, driveMovePollInterval = 1, 0
t.Cleanup(func() {
driveMovePollAttempts, driveMovePollInterval = prevAttempts, prevInterval
})
err := mountAndRunDrive(t, DriveMove, []string{
"+move",
"--file-token", "fld_src",
"--type", "folder",
"--folder-token", "fld_dst",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected 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(`"timed_out": true`)) {
t.Fatalf("stdout missing timed_out=true: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"next_command": "lark-cli drive +task_result --scenario task_check --task-id task_123"`)) {
t.Fatalf("stdout missing follow-up command: %s", stdout.String())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/fld_src/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{"task_id": "task_123"},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/drive/v1/files/task_check",
Body: tt.taskCheckBody,
})
withSingleDriveTaskCheckPoll(t)
err := mountAndRunDrive(t, DriveMove, []string{
"+move",
"--file-token", "fld_src",
"--type", "folder",
"--folder-token", "fld_dst",
"--as", "bot",
}, f, stdout)
if tt.wantErrContains != "" {
if err == nil {
t.Fatal("expected task_check polling error, got nil")
}
if !bytes.Contains([]byte(err.Error()), []byte(tt.wantErrContains)) {
t.Fatalf("unexpected error: %v", err)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for _, needle := range tt.wantStdout {
if !bytes.Contains(stdout.Bytes(), []byte(needle)) {
t.Fatalf("stdout missing %q: %s", needle, stdout.String())
}
}
})
}
}

View File

@@ -5,27 +5,31 @@ package drive
import (
"context"
"errors"
"fmt"
"strings"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// DriveTaskResult exposes a unified read path for the async task types produced
// by Drive import, export, and folder move flows.
// by Drive import, export, folder move/delete, and wiki move flows.
var DriveTaskResult = common.Shortcut{
Service: "drive",
Command: "+task_result",
Description: "Poll async task result for import, export, move, or delete operations",
Description: "Poll async task result for import, export, drive move/delete, or wiki move operations",
Risk: "read",
Scopes: []string{"drive:drive.metadata:readonly"},
AuthTypes: []string{"user", "bot"},
// This shortcut multiplexes multiple backend APIs with different scope
// requirements, so scenario-specific prechecks are handled in Validate.
Scopes: []string{},
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 move/delete folder tasks)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, or task_check", Required: true},
{Name: "task-id", Desc: "async task ID (for drive task_check or wiki_move tasks)", Required: false},
{Name: "scenario", Desc: "task scenario: import, export, task_check, or wiki_move", 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 {
@@ -34,9 +38,10 @@ var DriveTaskResult = common.Shortcut{
"import": true,
"export": true,
"task_check": true,
"wiki_move": true,
}
if !validScenarios[scenario] {
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check", scenario)
return output.ErrValidation("unsupported scenario: %s. Supported scenarios: import, export, task_check, wiki_move", scenario)
}
// Validate required params based on scenario
@@ -48,9 +53,9 @@ var DriveTaskResult = common.Shortcut{
if err := validate.ResourceName(runtime.Str("ticket"), "--ticket"); err != nil {
return output.ErrValidation("%s", err)
}
case "task_check":
case "task_check", "wiki_move":
if runtime.Str("task-id") == "" {
return output.ErrValidation("--task-id is required for task_check scenario")
return output.ErrValidation("--task-id is required for %s scenario", scenario)
}
if err := validate.ResourceName(runtime.Str("task-id"), "--task-id"); err != nil {
return output.ErrValidation("%s", err)
@@ -67,7 +72,7 @@ var DriveTaskResult = common.Shortcut{
}
}
return nil
return validateDriveTaskResultScopes(ctx, runtime, scenario)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
scenario := strings.ToLower(runtime.Str("scenario"))
@@ -92,6 +97,11 @@ var DriveTaskResult = common.Shortcut{
dry.GET("/open-apis/drive/v1/files/task_check").
Desc("[1] Query move/delete folder 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"})
}
return dry
@@ -116,6 +126,8 @@ var DriveTaskResult = common.Shortcut{
result, err = queryExportTask(runtime, ticket, fileToken)
case "task_check":
result, err = queryTaskCheck(runtime, taskID)
case "wiki_move":
result, err = queryWikiMoveTask(runtime, taskID)
}
if err != nil {
@@ -196,3 +208,263 @@ func queryTaskCheck(runtime *common.RuntimeContext, taskID string) (map[string]i
"failed": status.Failed(),
}, nil
}
func validateDriveTaskResultScopes(ctx context.Context, runtime *common.RuntimeContext, scenario string) error {
result, err := runtime.Factory.Credential.ResolveToken(ctx, credential.NewTokenSpec(runtime.As(), runtime.Config.AppID))
if err != nil {
// Propagate cancellation/timeout so callers stop instead of falling through
// to the API call. Other token errors are non-fatal here: the API call will
// surface a clearer permission error.
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return err
}
return nil
}
if result == nil || result.Scopes == "" {
return nil
}
var required []string
switch scenario {
case "import", "export", "task_check":
required = []string{"drive:drive.metadata:readonly"}
case "wiki_move":
required = []string{"wiki:space:read"}
}
return requireDriveScopes(result.Scopes, required)
}
func requireDriveScopes(storedScopes string, required []string) error {
if len(required) == 0 {
return nil
}
missing := missingDriveScopes(storedScopes, required)
if len(missing) == 0 {
return nil
}
return output.ErrWithHint(output.ExitAuth, "missing_scope",
fmt.Sprintf("missing required scope(s): %s", strings.Join(missing, ", ")),
fmt.Sprintf("run `lark-cli auth login --scope \"%s\"` in the background. It blocks and outputs a verification URL — retrieve the URL and open it in a browser to complete login.", strings.Join(missing, " ")))
}
func missingDriveScopes(storedScopes string, required []string) []string {
granted := make(map[string]bool)
for _, scope := range strings.Fields(storedScopes) {
granted[scope] = true
}
missing := make([]string, 0, len(required))
for _, scope := range required {
if !granted[scope] {
missing = append(missing, scope)
}
}
return missing
}
type wikiMoveTaskResultStatus struct {
Node map[string]interface{}
Status int
StatusMsg string
}
type wikiMoveTaskQueryStatus struct {
TaskID string
MoveResults []wikiMoveTaskResultStatus
}
func (s wikiMoveTaskQueryStatus) Ready() bool {
if len(s.MoveResults) == 0 {
return false
}
for _, result := range s.MoveResults {
if result.Status != 0 {
return false
}
}
return true
}
func (s wikiMoveTaskQueryStatus) Failed() bool {
for _, result := range s.MoveResults {
if result.Status < 0 {
return true
}
}
return false
}
func (s wikiMoveTaskQueryStatus) FirstResult() *wikiMoveTaskResultStatus {
if len(s.MoveResults) == 0 {
return nil
}
return &s.MoveResults[0]
}
// primaryResult picks the most informative move_result for top-level status
// surfacing: prefer a failing entry so multi-doc tasks don't mask failures
// behind an earlier success, then a still-processing entry, and finally fall
// back to the first entry.
func (s wikiMoveTaskQueryStatus) primaryResult() *wikiMoveTaskResultStatus {
for i := range s.MoveResults {
if s.MoveResults[i].Status < 0 {
return &s.MoveResults[i]
}
}
for i := range s.MoveResults {
if s.MoveResults[i].Status > 0 {
return &s.MoveResults[i]
}
}
return s.FirstResult()
}
func (s wikiMoveTaskQueryStatus) PrimaryStatusCode() int {
if r := s.primaryResult(); r != nil {
return r.Status
}
return 1
}
func (s wikiMoveTaskQueryStatus) PrimaryStatusLabel() string {
if r := s.primaryResult(); r != nil {
if msg := strings.TrimSpace(r.StatusMsg); msg != "" {
return msg
}
}
switch {
case s.Ready():
return "success"
case s.Failed():
return "failure"
default:
return "processing"
}
}
func queryWikiMoveTask(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
status, err := getWikiMoveTaskStatus(runtime, taskID)
if err != nil {
return nil, err
}
out := map[string]interface{}{
"scenario": "wiki_move",
"task_id": status.TaskID,
"ready": status.Ready(),
"failed": status.Failed(),
"status": status.PrimaryStatusCode(),
"status_msg": status.PrimaryStatusLabel(),
}
moveResults := make([]map[string]interface{}, 0, len(status.MoveResults))
for _, result := range status.MoveResults {
item := map[string]interface{}{
"status": result.Status,
"status_msg": result.StatusMsg,
}
if result.Node != nil {
item["node"] = result.Node
}
moveResults = append(moveResults, item)
}
if len(moveResults) > 0 {
out["move_results"] = moveResults
}
if first := status.FirstResult(); first != nil {
// Mirror the first moved node at the top level so follow-up commands can
// reuse a stable field set without digging into move_results[0].node.
if first.Node != nil {
out["node"] = first.Node
appendWikiMoveNodeFields(out, first.Node)
if token := common.GetString(first.Node, "node_token"); token != "" {
out["wiki_token"] = token
}
}
}
return out, nil
}
func getWikiMoveTaskStatus(runtime *common.RuntimeContext, taskID string) (wikiMoveTaskQueryStatus, error) {
if err := validate.ResourceName(taskID, "--task-id"); err != nil {
return wikiMoveTaskQueryStatus{}, output.ErrValidation("%s", err)
}
data, err := runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "move"},
nil,
)
if err != nil {
return wikiMoveTaskQueryStatus{}, err
}
return parseWikiMoveTaskQueryStatus(taskID, common.GetMap(data, "task"))
}
func parseWikiMoveTaskQueryStatus(taskID string, task map[string]interface{}) (wikiMoveTaskQueryStatus, error) {
if task == nil {
return wikiMoveTaskQueryStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
}
status := wikiMoveTaskQueryStatus{
TaskID: common.GetString(task, "task_id"),
}
if status.TaskID == "" {
status.TaskID = taskID
}
for _, item := range common.GetSlice(task, "move_result") {
resultMap, ok := item.(map[string]interface{})
if !ok {
continue
}
status.MoveResults = append(status.MoveResults, wikiMoveTaskResultStatus{
Node: parseWikiMoveTaskNode(common.GetMap(resultMap, "node")),
Status: int(common.GetFloat(resultMap, "status")),
StatusMsg: common.GetString(resultMap, "status_msg"),
})
}
return status, nil
}
func parseWikiMoveTaskNode(node map[string]interface{}) map[string]interface{} {
if node == nil {
return nil
}
return map[string]interface{}{
"space_id": common.GetString(node, "space_id"),
"node_token": common.GetString(node, "node_token"),
"obj_token": common.GetString(node, "obj_token"),
"obj_type": common.GetString(node, "obj_type"),
"parent_node_token": common.GetString(node, "parent_node_token"),
"node_type": common.GetString(node, "node_type"),
"origin_node_token": common.GetString(node, "origin_node_token"),
"title": common.GetString(node, "title"),
"has_child": common.GetBool(node, "has_child"),
}
}
func appendWikiMoveNodeFields(out, node map[string]interface{}) {
if out == nil || node == nil {
return
}
out["space_id"] = common.GetString(node, "space_id")
out["node_token"] = common.GetString(node, "node_token")
out["obj_token"] = common.GetString(node, "obj_token")
out["obj_type"] = common.GetString(node, "obj_type")
out["parent_node_token"] = common.GetString(node, "parent_node_token")
out["node_type"] = common.GetString(node, "node_type")
out["origin_node_token"] = common.GetString(node, "origin_node_token")
out["title"] = common.GetString(node, "title")
out["has_child"] = common.GetBool(node, "has_child")
}

View File

@@ -7,12 +7,15 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
@@ -54,6 +57,13 @@ func TestDriveTaskResultValidateErrorsByScenario(t *testing.T) {
},
wantErr: "--task-id is required",
},
{
name: "wiki move missing task id",
flags: map[string]string{
"scenario": "wiki_move",
},
wantErr: "--task-id is required",
},
}
for _, tt := range tests {
@@ -246,3 +256,290 @@ func TestDriveTaskResultTaskCheckIncludesReadyFlags(t *testing.T) {
t.Fatalf("stdout missing failed=false: %s", stdout.String())
}
}
func TestDriveTaskResultTaskCheckTreatsFailAsFailed(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
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": "fail"},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "task_check",
"--task-id", "task_123",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !bytes.Contains(stdout.Bytes(), []byte(`"status": "fail"`)) {
t.Fatalf("stdout missing fail status: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"failed": true`)) {
t.Fatalf("stdout missing failed=true: %s", stdout.String())
}
if !bytes.Contains(stdout.Bytes(), []byte(`"ready": false`)) {
t.Fatalf("stdout missing ready=false: %s", stdout.String())
}
}
type mockDriveTaskResultTokenResolver struct {
token string
scopes string
err error
}
func (m *mockDriveTaskResultTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
if m.err != nil {
return nil, m.err
}
token := m.token
if token == "" {
token = "test-token"
}
return &credential.TokenResult{Token: token, Scopes: m.scopes}, nil
}
func newDriveTaskResultRuntimeWithScopes(t *testing.T, as core.Identity, scopes string) *common.RuntimeContext {
t.Helper()
cfg := driveTestConfig()
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockDriveTaskResultTokenResolver{scopes: scopes}, nil)
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, as)
runtime.Factory = factory
return runtime
}
func TestDriveTaskResultDryRunWikiMoveIncludesTaskTypeParam(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"); err != nil {
t.Fatalf("set --scenario: %v", err)
}
if err := cmd.Flags().Set("task-id", "task_123"); 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 {
t.Fatalf("expected 1 API call, got %d", len(got.API))
}
if got.API[0].Params["task_type"] != "move" {
t.Fatalf("wiki move params = %#v, want task_type=move", got.API[0].Params)
}
}
func TestDriveTaskResultWikiMoveIncludesFlattenedNodeFields(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/task_123",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
"task_id": "task_123",
"move_result": []interface{}{
map[string]interface{}{
"status": 0,
"status_msg": "success",
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_done",
"obj_token": "sheet_token",
"obj_type": "sheet",
"node_type": "origin",
"title": "Roadmap",
},
},
},
},
},
},
})
err := mountAndRunDrive(t, DriveTaskResult, []string{
"+task_result",
"--scenario", "wiki_move",
"--task-id", "task_123",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDriveEnvelope(t, stdout)
if data["scenario"] != "wiki_move" || data["task_id"] != "task_123" {
t.Fatalf("unexpected wiki_move envelope: %#v", data)
}
if data["ready"] != true || data["failed"] != false || data["wiki_token"] != "wik_done" {
t.Fatalf("unexpected readiness fields: %#v", data)
}
if data["title"] != "Roadmap" || data["obj_type"] != "sheet" || data["space_id"] != "space_dst" {
t.Fatalf("flattened node fields missing: %#v", data)
}
moveResults, ok := data["move_results"].([]interface{})
if !ok || len(moveResults) != 1 {
t.Fatalf("move_results = %#v, want one result", data["move_results"])
}
}
func TestValidateDriveTaskResultScopesWikiMoveRequiresWikiScope(t *testing.T) {
t.Parallel()
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "drive:drive.metadata:readonly")
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
if err == nil || !strings.Contains(err.Error(), "missing required scope(s): wiki:space:read") {
t.Fatalf("expected missing wiki scope error, got %v", err)
}
}
func TestValidateDriveTaskResultScopesWikiMoveAcceptsWikiScope(t *testing.T) {
t.Parallel()
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "wiki:space:read")
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
if err != nil {
t.Fatalf("validateDriveTaskResultScopes() error = %v", err)
}
}
func TestValidateDriveTaskResultScopesDriveScenariosRequireDriveScope(t *testing.T) {
t.Parallel()
runtime := newDriveTaskResultRuntimeWithScopes(t, core.AsUser, "wiki:space:read")
err := validateDriveTaskResultScopes(context.Background(), runtime, "import")
if err == nil || !strings.Contains(err.Error(), "missing required scope(s): drive:drive.metadata:readonly") {
t.Fatalf("expected missing drive scope error, got %v", err)
}
}
func TestParseWikiMoveTaskQueryStatusFallbackTaskIDAndNode(t *testing.T) {
t.Parallel()
status, err := parseWikiMoveTaskQueryStatus("task_fallback", map[string]interface{}{
"move_result": []interface{}{
map[string]interface{}{
"status": 0,
"status_msg": "success",
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_done",
"obj_token": "sheet_token",
"obj_type": "sheet",
"title": "Roadmap",
},
},
},
})
if err != nil {
t.Fatalf("parseWikiMoveTaskQueryStatus() error = %v", err)
}
if status.TaskID != "task_fallback" || !status.Ready() || status.PrimaryStatusLabel() != "success" {
t.Fatalf("unexpected parsed status: %+v", status)
}
if first := status.FirstResult(); first == nil || first.Node == nil || first.Node["node_token"] != "wik_done" {
t.Fatalf("parsed node = %+v", first)
}
}
func TestParseWikiMoveTaskQueryStatusRejectsMissingTask(t *testing.T) {
t.Parallel()
_, err := parseWikiMoveTaskQueryStatus("task_123", nil)
if err == nil || !strings.Contains(err.Error(), "missing task") {
t.Fatalf("expected missing task error, got %v", err)
}
}
func TestWikiMoveTaskQueryStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) {
t.Parallel()
status := wikiMoveTaskQueryStatus{
MoveResults: []wikiMoveTaskResultStatus{
{Status: 0, StatusMsg: "success"},
{Status: -3, StatusMsg: "permission denied"},
{Status: 1, StatusMsg: "processing"},
},
}
if got := status.PrimaryStatusCode(); got != -3 {
t.Fatalf("PrimaryStatusCode = %d, want -3", got)
}
if got := status.PrimaryStatusLabel(); got != "permission denied" {
t.Fatalf("PrimaryStatusLabel = %q, want permission denied", got)
}
// FirstResult must keep its literal "first entry" semantics for callers
// that flatten node fields from the first move_result.
if first := status.FirstResult(); first == nil || first.StatusMsg != "success" {
t.Fatalf("FirstResult = %+v, want first success entry", first)
}
}
func TestWikiMoveTaskQueryStatusPrimaryPrefersProcessingOverFirstSuccess(t *testing.T) {
t.Parallel()
status := wikiMoveTaskQueryStatus{
MoveResults: []wikiMoveTaskResultStatus{
{Status: 0, StatusMsg: "success"},
{Status: 1, StatusMsg: "processing"},
},
}
if got := status.PrimaryStatusCode(); got != 1 {
t.Fatalf("PrimaryStatusCode = %d, want 1", got)
}
if got := status.PrimaryStatusLabel(); got != "processing" {
t.Fatalf("PrimaryStatusLabel = %q, want processing", got)
}
}
type cancelingTokenResolver struct{}
func (cancelingTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
return nil, context.Canceled
}
func TestValidateDriveTaskResultScopesPropagatesContextCancellation(t *testing.T) {
t.Parallel()
cfg := driveTestConfig()
factory, _, _, _ := cmdutil.TestFactory(t, cfg)
factory.Credential = credential.NewCredentialProvider(nil, nil, cancelingTokenResolver{}, nil)
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "drive +task_result"}, cfg, core.AsUser)
runtime.Factory = factory
err := validateDriveTaskResultScopes(context.Background(), runtime, "wiki_move")
if err == nil || !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
}

View File

@@ -9,12 +9,14 @@ import "github.com/larksuite/cli/shortcuts/common"
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
DriveUpload,
DriveCreateShortcut,
DriveDownload,
DriveAddComment,
DriveExport,
DriveExportDownload,
DriveImport,
DriveMove,
DriveDelete,
DriveTaskResult,
}
}

View File

@@ -5,18 +5,21 @@ package drive
import "testing"
// TestShortcutsIncludesExpectedCommands verifies the drive shortcut registry contains the expected commands.
func TestShortcutsIncludesExpectedCommands(t *testing.T) {
t.Parallel()
got := Shortcuts()
want := []string{
"+upload",
"+create-shortcut",
"+download",
"+add-comment",
"+export",
"+export-download",
"+import",
"+move",
"+delete",
"+task_result",
}

View File

@@ -74,6 +74,7 @@ var commonEventTypes = []string{
"approval.approval.updated",
"application.application.visibility.added_v6",
"task.task.update_tenant_v1",
"task.task.update_user_access_v2",
"task.task.comment_updated_v1",
"drive.notice.comment_add_v1",
}

View File

@@ -10,6 +10,7 @@ import (
"strings"
"testing"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
@@ -395,6 +396,28 @@ func TestShortcutValidateBranches(t *testing.T) {
}
})
t.Run("ImChatMessageList rejects both targets", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"chat-id": "oc_abc",
"user-id": "ou_123",
}, nil)
err := ImChatMessageList.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
t.Fatalf("ImChatMessageList.Validate() error = %v, want mutually exclusive", err)
}
})
t.Run("ImChatMessageList rejects user target for bot identity", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"user-id": "ou_123",
}, nil)
setRuntimeField(t, runtime, "resolvedAs", core.AsBot)
err := ImChatMessageList.Validate(context.Background(), runtime)
if err == nil || !strings.Contains(err.Error(), "requires user identity") {
t.Fatalf("ImChatMessageList.Validate() error = %v, want requires user identity", err)
}
})
t.Run("ImMessagesMGet empty ids", func(t *testing.T) {
runtime := newTestRuntimeContext(t, map[string]string{
"message-ids": " , ",

View File

@@ -273,7 +273,7 @@ func TestResolveChatIDForMessagesList(t *testing.T) {
})
t.Run("user resolved through p2p lookup", func(t *testing.T) {
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chat_p2p/batch_query"):
return shortcutJSONResponse(200, map[string]interface{}{
@@ -303,6 +303,23 @@ func TestResolveChatIDForMessagesList(t *testing.T) {
t.Fatalf("resolveChatIDForMessagesList() = %q, want %q", got, "oc_resolved")
}
})
t.Run("user target rejected for bot identity", func(t *testing.T) {
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}))
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("user-id", "", "")
if err := cmd.Flags().Set("user-id", "ou_123"); err != nil {
t.Fatalf("Flags().Set() error = %v", err)
}
runtime.Cmd = cmd
_, err := resolveChatIDForMessagesList(runtime, false)
if err == nil || !strings.Contains(err.Error(), "requires user identity") {
t.Fatalf("resolveChatIDForMessagesList() error = %v, want requires user identity", err)
}
})
}
func TestBuildMessagesSearchRequest(t *testing.T) {

View File

@@ -377,6 +377,9 @@ func mediaFallbackOrError(originalValue, mediaType string, uploadErr error) (str
// resolveP2PChatID resolves user open_id to P2P chat_id.
func resolveP2PChatID(runtime *common.RuntimeContext, openID string) (string, error) {
if runtime.IsBot() {
return "", output.Errorf(output.ExitValidation, "validation", "--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
}
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/im/v1/chat_p2p/batch_query",

View File

@@ -6,6 +6,7 @@ package im
import (
"bytes"
"context"
"crypto/md5"
"encoding/json"
"fmt"
"io"
@@ -13,6 +14,7 @@ import (
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"unsafe"
@@ -107,12 +109,17 @@ func newBotShortcutRuntime(t *testing.T, rt http.RoundTripper) *common.RuntimeCo
return runtime
}
func newUserShortcutRuntime(t *testing.T, rt http.RoundTripper) *common.RuntimeContext {
t.Helper()
runtime := newBotShortcutRuntime(t, rt)
setRuntimeField(t, runtime, "resolvedAs", core.AsUser)
return runtime
}
func TestResolveP2PChatID(t *testing.T) {
var gotAuth string
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chat_p2p/batch_query"):
gotAuth = req.Header.Get("Authorization")
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
@@ -133,13 +140,10 @@ func TestResolveP2PChatID(t *testing.T) {
if got != "oc_123" {
t.Fatalf("resolveP2PChatID() = %q, want %q", got, "oc_123")
}
if gotAuth != "Bearer tenant-token" {
t.Fatalf("Authorization header = %q, want %q", gotAuth, "Bearer tenant-token")
}
}
func TestResolveP2PChatIDNotFound(t *testing.T) {
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
runtime := newUserShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/chat_p2p/batch_query"):
return shortcutJSONResponse(200, map[string]interface{}{
@@ -159,6 +163,17 @@ func TestResolveP2PChatIDNotFound(t *testing.T) {
}
}
func TestResolveP2PChatIDRejectsBot(t *testing.T) {
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}))
_, err := resolveP2PChatID(runtime, "ou_123")
if err == nil || !strings.Contains(err.Error(), "requires user identity") {
t.Fatalf("resolveP2PChatID() error = %v, want requires user identity", err)
}
}
func TestResolveThreadID(t *testing.T) {
t.Run("thread id passthrough", func(t *testing.T) {
got, err := resolveThreadID(newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
@@ -273,6 +288,46 @@ func TestDownloadIMResourceToPathSuccess(t *testing.T) {
if gotHeaders.Get(cmdutil.HeaderExecutionId) != "exec-123" {
t.Fatalf("%s = %q, want %q", cmdutil.HeaderExecutionId, gotHeaders.Get(cmdutil.HeaderExecutionId), "exec-123")
}
if gotHeaders.Get("Range") != fmt.Sprintf("bytes=0-%d", probeChunkSize-1) {
t.Fatalf("Range header = %q, want %q", gotHeaders.Get("Range"), fmt.Sprintf("bytes=0-%d", probeChunkSize-1))
}
}
func TestDownloadIMResourceToPathImageUsesSingleRequestWithoutRange(t *testing.T) {
var gotHeaders http.Header
payload := []byte("image download")
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_img/resources/img_123"):
gotHeaders = req.Header.Clone()
return shortcutRawResponse(200, payload, http.Header{"Content-Type": []string{"image/png"}}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
gotPath, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_img", "img_123", "image", "image")
if err != nil {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
if size != int64(len(payload)) {
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
}
if gotHeaders.Get("Range") != "" {
t.Fatalf("Range header = %q, want empty", gotHeaders.Get("Range"))
}
if !strings.HasSuffix(gotPath, "image.png") {
t.Fatalf("saved path = %q, want suffix %q", gotPath, "image.png")
}
data, err := os.ReadFile("image.png")
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if string(data) != string(payload) {
t.Fatalf("downloaded payload = %q, want %q", string(data), string(payload))
}
}
func TestDownloadIMResourceToPathHTTPErrorBody(t *testing.T) {
@@ -293,6 +348,348 @@ func TestDownloadIMResourceToPathHTTPErrorBody(t *testing.T) {
}
}
func TestDownloadIMResourceToPathRetriesNetworkError(t *testing.T) {
attempts := 0
payload := []byte("retry success")
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"tenant_access_token": "tenant-token",
"expire": 7200,
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_retry/resources/file_retry"):
attempts++
if attempts < 3 {
return nil, fmt.Errorf("temporary network failure")
}
return shortcutRawResponse(200, payload, http.Header{"Content-Type": []string{"application/octet-stream"}}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry", "file_retry", "file", target)
if err != nil {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
if attempts != 3 {
t.Fatalf("download attempts = %d, want 3", attempts)
}
if size != int64(len(payload)) {
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
}
}
func TestDownloadIMResourceToPathRetrySecondAttemptSuccess(t *testing.T) {
attempts := 0
payload := []byte("second retry success")
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"tenant_access_token": "tenant-token",
"expire": 7200,
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_retry2/resources/file_retry2"):
attempts++
if attempts < 2 {
return nil, fmt.Errorf("temporary network failure")
}
return shortcutRawResponse(200, payload, http.Header{"Content-Type": []string{"application/octet-stream"}}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_retry2", "file_retry2", "file", target)
if err != nil {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
if attempts != 2 {
t.Fatalf("download attempts = %d, want 2", attempts)
}
if size != int64(len(payload)) {
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
}
}
func TestDownloadIMResourceToPathRetryContextCanceled(t *testing.T) {
attempts := 0
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"tenant_access_token": "tenant-token",
"expire": 7200,
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_cancel/resources/file_cancel"):
attempts++
return nil, fmt.Errorf("temporary network failure")
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
ctx, cancel := context.WithCancel(context.Background())
// Cancel context immediately to trigger context error on first retry
cancel()
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, _, err := downloadIMResourceToPath(ctx, runtime, "om_cancel", "file_cancel", "file", target)
if err != context.Canceled {
t.Fatalf("downloadIMResourceToPath() error = %v, want context.Canceled", err)
}
// First attempt is made, then retry checks ctx.Err() and returns
if attempts != 1 {
t.Fatalf("download attempts = %d, want 1", attempts)
}
}
func TestDownloadIMResourceToPathRangeDownload(t *testing.T) {
cases := []struct {
name string
payloadLen int64
wantRanges []string
}{
{
name: "single small chunk",
payloadLen: 16,
wantRanges: []string{"bytes=0-131071"},
},
{
name: "exact probe chunk",
payloadLen: probeChunkSize,
wantRanges: []string{"bytes=0-131071"},
},
{
name: "multiple chunks with tail",
payloadLen: probeChunkSize + normalChunkSize + 1234,
wantRanges: []string{
"bytes=0-131071",
fmt.Sprintf("bytes=%d-%d", probeChunkSize, probeChunkSize+normalChunkSize-1),
fmt.Sprintf("bytes=%d-%d", probeChunkSize+normalChunkSize, probeChunkSize+normalChunkSize+1233),
},
},
{
name: "multiple chunks exact 8mb tail",
payloadLen: probeChunkSize + 2*normalChunkSize,
wantRanges: []string{
"bytes=0-131071",
fmt.Sprintf("bytes=%d-%d", probeChunkSize, probeChunkSize+normalChunkSize-1),
fmt.Sprintf("bytes=%d-%d", probeChunkSize+normalChunkSize, probeChunkSize+2*normalChunkSize-1),
},
},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
payload := bytes.Repeat([]byte("range-download-"), int(tt.payloadLen/15)+1)
payload = payload[:tt.payloadLen]
var gotRanges []string
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"tenant_access_token": "tenant-token",
"expire": 7200,
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_range/resources/file_range"):
rangeHeader := req.Header.Get("Range")
gotRanges = append(gotRanges, rangeHeader)
if req.Header.Get("Authorization") != "Bearer tenant-token" {
return nil, fmt.Errorf("missing authorization header")
}
start, end, err := parseRangeHeader(rangeHeader, int64(len(payload)))
if err != nil {
return nil, err
}
return shortcutRawResponse(http.StatusPartialContent, payload[start:end+1], http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(payload))},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
target := filepath.Join("nested", "resource.bin")
_, size, err := downloadIMResourceToPath(context.Background(), runtime, "om_range", "file_range", "file", target)
if err != nil {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
if size != int64(len(payload)) {
t.Fatalf("downloadIMResourceToPath() size = %d, want %d", size, len(payload))
}
if !reflect.DeepEqual(gotRanges, tt.wantRanges) {
t.Fatalf("Range requests = %#v, want %#v", gotRanges, tt.wantRanges)
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
if md5.Sum(got) != md5.Sum(payload) {
t.Fatalf("downloaded payload MD5 = %x, want %x", md5.Sum(got), md5.Sum(payload))
}
})
}
}
func TestDownloadIMResourceToPathInvalidContentRange(t *testing.T) {
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "tenant_access_token"):
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"tenant_access_token": "tenant-token",
"expire": 7200,
}), nil
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_bad/resources/file_bad"):
return shortcutRawResponse(http.StatusPartialContent, []byte("bad"), http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Range": []string{"bytes 0-2/not-a-number"},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_bad", "file_bad", "file", "out.bin")
if err == nil || !strings.Contains(err.Error(), "invalid Content-Range header") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
}
func TestDownloadIMResourceToPathRangeChunkFailureCleansOutput(t *testing.T) {
payload := bytes.Repeat([]byte("range-download-"), int((probeChunkSize+1024)/15)+1)
payload = payload[:probeChunkSize+1024]
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_miderr/resources/file_miderr"):
rangeHeader := req.Header.Get("Range")
if rangeHeader == fmt.Sprintf("bytes=0-%d", probeChunkSize-1) {
return shortcutRawResponse(http.StatusPartialContent, payload[:probeChunkSize], http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Range": []string{fmt.Sprintf("bytes 0-%d/%d", probeChunkSize-1, len(payload))},
}), nil
}
return shortcutRawResponse(http.StatusInternalServerError, []byte("chunk failed"), http.Header{"Content-Type": []string{"text/plain"}}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_miderr", "file_miderr", "file", target)
if err == nil || !strings.Contains(err.Error(), "HTTP 500: chunk failed") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Fatalf("output file exists after failed download, stat error = %v", statErr)
}
}
func TestDownloadIMResourceToPathRangeOverflowCleansOutput(t *testing.T) {
payload := []byte("overflow-payload")
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_overflow/resources/file_overflow"):
return shortcutRawResponse(http.StatusPartialContent, payload, http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Range": []string{"bytes 0-3/4"},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
target := "out.bin"
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_overflow", "file_overflow", "file", target)
if err == nil || !strings.Contains(err.Error(), "chunk overflow") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
if _, statErr := os.Stat(target); !os.IsNotExist(statErr) {
t.Fatalf("output file exists after overflow, stat error = %v", statErr)
}
}
func TestDownloadIMResourceToPathRangeShortChunkSizeMismatch(t *testing.T) {
payload := bytes.Repeat([]byte("range-download-"), int((probeChunkSize+1024)/15)+1)
payload = payload[:probeChunkSize+1024]
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
switch {
case strings.Contains(req.URL.Path, "/open-apis/im/v1/messages/om_short/resources/file_short"):
rangeHeader := req.Header.Get("Range")
start, end, err := parseRangeHeader(rangeHeader, int64(len(payload)))
if err != nil {
return nil, err
}
body := payload[start : end+1]
if start == probeChunkSize {
body = body[:len(body)-10]
}
return shortcutRawResponse(http.StatusPartialContent, body, http.Header{
"Content-Type": []string{"application/octet-stream"},
"Content-Range": []string{fmt.Sprintf("bytes %d-%d/%d", start, end, len(payload))},
}), nil
default:
return nil, fmt.Errorf("unexpected request: %s", req.URL.String())
}
}))
cmdutil.TestChdir(t, t.TempDir())
_, _, err := downloadIMResourceToPath(context.Background(), runtime, "om_short", "file_short", "file", "out.bin")
if err == nil || !strings.Contains(err.Error(), "file size mismatch") {
t.Fatalf("downloadIMResourceToPath() error = %v", err)
}
}
func parseRangeHeader(header string, totalSize int64) (int64, int64, error) {
if !strings.HasPrefix(header, "bytes=") {
return 0, 0, fmt.Errorf("unexpected range header: %q", header)
}
parts := strings.SplitN(strings.TrimPrefix(header, "bytes="), "-", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("unexpected range header: %q", header)
}
start, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("parse start: %w", err)
}
end, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, 0, fmt.Errorf("parse end: %w", err)
}
if start < 0 || end < start || start >= totalSize {
return 0, 0, fmt.Errorf("invalid range bounds: %d-%d for size %d", start, end, totalSize)
}
if end >= totalSize {
end = totalSize - 1
}
return start, end, nil
}
func TestUploadImageToIMSuccess(t *testing.T) {
var gotBody string
runtime := newBotShortcutRuntime(t, shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {

View File

@@ -599,6 +599,44 @@ func TestDownloadIMResourceToPathHTTPClientError(t *testing.T) {
}
}
func TestParseTotalSize(t *testing.T) {
tests := []struct {
name string
contentRange string
want int64
wantErr string
}{
{name: "normal", contentRange: "bytes 0-131071/104857600", want: 104857600},
{name: "single probe chunk", contentRange: "bytes 0-131071/131072", want: 131072},
{name: "single small chunk", contentRange: "bytes 0-15/16", want: 16},
{name: "empty", contentRange: "", wantErr: "content-range is empty"},
{name: "invalid prefix", contentRange: "items 0-15/16", wantErr: `unsupported content-range: "items 0-15/16"`},
{name: "missing total", contentRange: "bytes 0-15/", wantErr: `unsupported content-range: "bytes 0-15/"`},
{name: "wildcard", contentRange: "bytes */16", wantErr: `unsupported content-range: "bytes */16"`},
{name: "unknown total size", contentRange: "bytes 0-99/*", wantErr: `unknown total size in content-range: "bytes 0-99/*"`},
{name: "invalid total", contentRange: "bytes 0-15/not-a-number", wantErr: "parse total size:"},
{name: "zero total size", contentRange: "bytes 0-0/0", wantErr: "invalid total size: 0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseTotalSize(tt.contentRange)
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("parseTotalSize() error = %v, want substring %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("parseTotalSize() unexpected error = %v", err)
}
if got != tt.want {
t.Fatalf("parseTotalSize() = %d, want %d", got, tt.want)
}
})
}
}
func TestShortcuts(t *testing.T) {
var commands []string
for _, shortcut := range Shortcuts() {

View File

@@ -28,7 +28,7 @@ var ImChatMessageList = common.Shortcut{
HasFormat: true,
Flags: []common.Flag{
{Name: "chat-id", Desc: "(required, mutually exclusive with --user-id) chat ID (oc_xxx)"},
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id) user open_id (ou_xxx)"},
{Name: "user-id", Desc: "(required, mutually exclusive with --chat-id; user identity only) user open_id (ou_xxx)"},
{Name: "start", Desc: "start time (ISO 8601)"},
{Name: "end", Desc: "end time (ISO 8601)"},
{Name: "sort", Default: "desc", Desc: "sort order", Enum: []string{"asc", "desc"}},
@@ -57,11 +57,21 @@ var ImChatMessageList = common.Shortcut{
return d.GET("/open-apis/im/v1/messages").Params(dryParams)
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil {
if runtime.Str("chat-id") == "" && runtime.Str("user-id") == "" {
return common.FlagErrorf("specify at least one of --chat-id or --user-id")
// Under bot identity, --user-id is not supported; require --chat-id only.
if runtime.IsBot() {
if runtime.Str("user-id") != "" {
return common.FlagErrorf("--user-id requires user identity (--as user); use --chat-id when calling with bot identity")
}
if runtime.Str("chat-id") == "" {
return common.FlagErrorf("specify --chat-id (bot identity does not support --user-id)")
}
} else {
if err := common.ExactlyOne(runtime, "chat-id", "user-id"); err != nil {
if runtime.Str("chat-id") == "" && runtime.Str("user-id") == "" {
return common.FlagErrorf("specify at least one of --chat-id or --user-id")
}
return err
}
return err
}
// Validate ID formats

View File

@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"path/filepath"
"strconv"
"strings"
"time"
@@ -67,6 +68,9 @@ var ImMessagesResourcesDownload = common.Shortcut{
if err != nil {
return output.ErrValidation("invalid output path: %s", err)
}
if _, err := runtime.ResolveSavePath(relPath); err != nil {
return output.ErrValidation("unsafe output path: %s", err)
}
finalPath, sizeBytes, err := downloadIMResourceToPath(ctx, runtime, messageId, fileKey, fileType, relPath)
if err != nil {
@@ -102,7 +106,13 @@ func normalizeDownloadOutputPath(fileKey, outputPath string) (string, error) {
return outputPath, nil
}
const defaultIMResourceDownloadTimeout = 120 * time.Second
const (
defaultIMResourceDownloadTimeout = 120 * time.Second
probeChunkSize = int64(128 * 1024)
normalChunkSize = int64(8 * 1024 * 1024)
imDownloadRequestRetries = 2
imDownloadRetryDelay = 300 * time.Millisecond
)
var imMimeToExt = map[string]string{
"image/png": ".png",
@@ -135,10 +145,199 @@ var imMimeToExt = map[string]string{
"application/vnd.openxmlformats-officedocument.presentationml.presentation": ".pptx",
}
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, safePath string) (string, int64, error) {
type rangeChunkReader struct {
ctx context.Context
runtime *common.RuntimeContext
messageID string
fileKey string
fileType string
totalSize int64
delivered int64
current io.ReadCloser
nextOffset int64
}
func newRangeChunkReader(
ctx context.Context,
runtime *common.RuntimeContext,
messageID, fileKey, fileType string,
probeBody io.ReadCloser,
totalSize int64,
) *rangeChunkReader {
return &rangeChunkReader{
ctx: ctx,
runtime: runtime,
messageID: messageID,
fileKey: fileKey,
fileType: fileType,
totalSize: totalSize,
current: probeBody,
nextOffset: probeChunkSize,
}
}
func (r *rangeChunkReader) Read(p []byte) (int, error) {
for {
if r.current != nil {
n, err := r.current.Read(p)
r.delivered += int64(n)
if r.delivered > r.totalSize {
if err == io.EOF {
closeErr := r.current.Close()
r.current = nil
if closeErr != nil {
return 0, closeErr
}
}
return 0, output.ErrNetwork("chunk overflow: delivered %d, expected %d", r.delivered, r.totalSize)
}
switch err {
case nil:
return n, nil
case io.EOF:
closeErr := r.current.Close()
r.current = nil
if closeErr != nil {
return n, closeErr
}
if r.delivered == r.totalSize {
if n > 0 {
return n, nil
}
return 0, io.EOF
}
if n > 0 {
return n, nil
}
default:
return n, err
}
}
if r.nextOffset >= r.totalSize {
if r.delivered == r.totalSize {
return 0, io.EOF
}
return 0, output.ErrNetwork("file size mismatch: expected %d, got %d", r.totalSize, r.delivered)
}
end := min(r.nextOffset+normalChunkSize-1, r.totalSize-1)
resp, err := doIMResourceDownloadRequest(r.ctx, r.runtime, r.messageID, r.fileKey, r.fileType, map[string]string{
"Range": fmt.Sprintf("bytes=%d-%d", r.nextOffset, end),
})
if err != nil {
return 0, err
}
if resp.StatusCode >= 400 {
defer resp.Body.Close()
return 0, downloadResponseError(resp)
}
if resp.StatusCode != http.StatusPartialContent {
resp.Body.Close()
return 0, output.ErrNetwork("unexpected status code: %d", resp.StatusCode)
}
r.current = resp.Body
r.nextOffset = end + 1
}
}
func (r *rangeChunkReader) Close() error {
if r.current == nil {
return nil
}
err := r.current.Close()
r.current = nil
return err
}
func initialIMResourceDownloadHeaders(fileType string) map[string]string {
if fileType != "file" {
return nil
}
return map[string]string{
"Range": fmt.Sprintf("bytes=0-%d", probeChunkSize-1),
}
}
func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType, outputPath string) (string, int64, error) {
downloadResp, err := doIMResourceDownloadRequest(ctx, runtime, messageID, fileKey, fileType, initialIMResourceDownloadHeaders(fileType))
if err != nil {
return "", 0, err
}
if downloadResp.StatusCode >= 400 {
defer downloadResp.Body.Close()
return "", 0, downloadResponseError(downloadResp)
}
finalPath := resolveIMResourceDownloadPath(outputPath, downloadResp.Header.Get("Content-Type"))
var (
body io.ReadCloser
sizeBytes int64
)
switch downloadResp.StatusCode {
case http.StatusPartialContent:
totalSize, err := parseTotalSize(downloadResp.Header.Get("Content-Range"))
if err != nil {
downloadResp.Body.Close()
return "", 0, output.ErrNetwork("invalid Content-Range header on range response: %s", err)
}
body = newRangeChunkReader(ctx, runtime, messageID, fileKey, fileType, downloadResp.Body, totalSize)
sizeBytes = totalSize
case http.StatusOK:
body = downloadResp.Body
sizeBytes = downloadResp.ContentLength
default:
downloadResp.Body.Close()
return "", 0, output.ErrNetwork("unexpected status code: %d", downloadResp.StatusCode)
}
defer body.Close()
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: downloadResp.Header.Get("Content-Type"),
ContentLength: sizeBytes,
}, body)
if err != nil {
return "", 0, common.WrapSaveErrorByCategory(err, "api_error")
}
if sizeBytes >= 0 && result.Size() != sizeBytes {
return "", 0, output.ErrNetwork("file size mismatch: expected %d, got %d", sizeBytes, result.Size())
}
savedPath, resolveErr := runtime.ResolveSavePath(finalPath)
if resolveErr != nil || savedPath == "" {
savedPath = finalPath
}
return savedPath, result.Size(), nil
}
func resolveIMResourceDownloadPath(safePath, contentType string) string {
if filepath.Ext(safePath) != "" {
return safePath
}
mimeType := strings.Split(contentType, ";")[0]
mimeType = strings.TrimSpace(mimeType)
if ext, ok := imMimeToExt[mimeType]; ok {
return safePath + ext
}
return safePath
}
func doIMResourceDownloadRequest(ctx context.Context, runtime *common.RuntimeContext, messageID, fileKey, fileType string, headers map[string]string) (*http.Response, error) {
query := larkcore.QueryParams{}
query.Set("type", fileType)
downloadResp, err := runtime.DoAPIStream(ctx, &larkcore.ApiReq{
headerValues := make(http.Header, len(headers))
for key, value := range headers {
headerValues.Set(key, value)
}
req := &larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/im/v1/messages/:message_id/resources/:file_key",
PathParams: larkcore.PathParams{
@@ -146,44 +345,73 @@ func downloadIMResourceToPath(ctx context.Context, runtime *common.RuntimeContex
"file_key": fileKey,
},
QueryParams: query,
}, client.WithTimeout(defaultIMResourceDownloadTimeout))
if err != nil {
return "", 0, err
}
defer downloadResp.Body.Close()
if downloadResp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(downloadResp.Body, 4096))
if len(body) > 0 {
return "", 0, output.ErrNetwork("download failed: HTTP %d: %s", downloadResp.StatusCode, strings.TrimSpace(string(body)))
var lastErr error
for attempt := 0; attempt <= imDownloadRequestRetries; attempt++ {
resp, err := runtime.DoAPIStream(ctx, req, client.WithTimeout(defaultIMResourceDownloadTimeout), client.WithHeaders(headerValues))
if err == nil {
return resp, nil
}
return "", 0, output.ErrNetwork("download failed: HTTP %d", downloadResp.StatusCode)
}
// Auto-detect extension from Content-Type if missing
finalPath := safePath
if filepath.Ext(safePath) == "" {
contentType := downloadResp.Header.Get("Content-Type")
mimeType := strings.Split(contentType, ";")[0]
mimeType = strings.TrimSpace(mimeType)
if ext, ok := imMimeToExt[mimeType]; ok {
finalPath = safePath + ext
if ctx.Err() != nil {
return nil, ctx.Err()
}
lastErr = err
if attempt == imDownloadRequestRetries {
break
}
sleepIMDownloadRetry(ctx, attempt)
}
result, err := runtime.FileIO().Save(finalPath, fileio.SaveOptions{
ContentType: downloadResp.Header.Get("Content-Type"),
ContentLength: downloadResp.ContentLength,
}, downloadResp.Body)
if err != nil {
return "", 0, output.Errorf(output.ExitInternal, "api_error", "%s",
common.WrapSaveError(err, "unsafe output path", "cannot create parent directory", "cannot create file"))
if lastErr != nil {
return nil, lastErr
}
savedPath, resolveErr := runtime.ResolveSavePath(finalPath)
if resolveErr != nil {
// Save succeeded — file is on disk. Fall back to the relative path
// rather than returning an error for a successfully written file.
savedPath = finalPath
}
return savedPath, result.Size(), nil
return nil, output.ErrNetwork("download request failed")
}
func sleepIMDownloadRetry(ctx context.Context, attempt int) {
delay := imDownloadRetryDelay * (1 << uint(attempt))
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
case <-timer.C:
}
}
func downloadResponseError(resp *http.Response) error {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if len(body) > 0 {
return output.ErrNetwork("download failed: HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return output.ErrNetwork("download failed: HTTP %d", resp.StatusCode)
}
func parseTotalSize(contentRange string) (int64, error) {
contentRange = strings.TrimSpace(contentRange)
if contentRange == "" {
return 0, fmt.Errorf("content-range is empty")
}
if !strings.HasPrefix(contentRange, "bytes ") {
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
}
parts := strings.SplitN(strings.TrimPrefix(contentRange, "bytes "), "/", 2)
if len(parts) != 2 || parts[1] == "" {
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
}
if parts[0] == "*" {
return 0, fmt.Errorf("unsupported content-range: %q", contentRange)
}
if parts[1] == "*" {
return 0, fmt.Errorf("unknown total size in content-range: %q", contentRange)
}
totalSize, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil {
return 0, fmt.Errorf("parse total size: %w", err)
}
if totalSize <= 0 {
return 0, fmt.Errorf("invalid total size: %d", totalSize)
}
return totalSize, nil
}

View File

@@ -95,7 +95,7 @@ var MailWatch = common.Shortcut{
Command: "+watch",
Description: "Watch for incoming mail events via WebSocket (requires scope mail:event and bot event mail.user_mailbox.event.message_received_v1 added). Run with --print-output-schema to see per-format field reference before parsing output.",
Risk: "read",
Scopes: []string{"mail:event", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
Scopes: []string{"mail:event", "mail:user_mailbox.event.mail_address:read", "mail:user_mailbox:readonly", "mail:user_mailbox.message:readonly", "mail:user_mailbox.message.address:read", "mail:user_mailbox.message.subject:read", "mail:user_mailbox.message.body:read"},
AuthTypes: []string{"user"},
Flags: []common.Flag{
{Name: "format", Default: "data", Desc: "json: NDJSON stream with ok/data envelope; data: bare NDJSON stream"},
@@ -192,36 +192,23 @@ var MailWatch = common.Shortcut{
msgFormat := runtime.Str("msg-format")
outputDir := runtime.Str("output-dir")
if outputDir != "" {
if outputDir == "~" || strings.HasPrefix(outputDir, "~/") {
home, err := vfs.UserHomeDir()
if err != nil {
return fmt.Errorf("cannot expand ~: %w", err)
}
if outputDir == "~" {
outputDir = home
} else {
outputDir = filepath.Join(home, outputDir[2:])
}
} else if filepath.IsAbs(outputDir) {
outputDir = filepath.Clean(outputDir)
} else {
safePath, err := validate.SafeOutputPath(outputDir)
if err != nil {
return err
}
outputDir = safePath
// Reject all tilde-prefixed paths — SafeOutputPath treats "~/x" as a
// literal relative path (creating a directory named "~"), which is
// confusing. This also covers ~user/path forms.
if strings.HasPrefix(outputDir, "~") {
return output.ErrValidation("--output-dir does not support ~ expansion; use a relative path like ./output instead")
}
// Resolve symlinks on the output directory so all writes use the real
// filesystem path. This prevents a symlink from redirecting writes to
// an unintended location (TOCTOU mitigation).
// Enforce CWD containment: reject absolute paths, path traversal,
// and symlink escapes. SafeOutputPath returns a resolved absolute path
// under CWD, preventing writes to arbitrary system directories.
safePath, err := validate.SafeOutputPath(outputDir)
if err != nil {
return err
}
outputDir = safePath
if err := vfs.MkdirAll(outputDir, 0700); err != nil {
return fmt.Errorf("cannot create output directory %q: %w", outputDir, err)
}
resolved, err := filepath.EvalSymlinks(outputDir)
if err != nil {
return fmt.Errorf("cannot resolve output directory: %w", err)
}
outputDir = resolved
}
labelIDsInput := runtime.Str("label-ids")
folderIDsInput := runtime.Str("folder-ids")

View File

@@ -0,0 +1,347 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
defaultMinutesSearchPageSize = 15
maxMinutesSearchPageSize = 30
maxMinutesSearchQueryLen = 50
)
// parseTimeRange normalizes --start and --end into RFC3339 timestamps.
func parseTimeRange(runtime *common.RuntimeContext) (string, string, error) {
start := strings.TrimSpace(runtime.Str("start"))
end := strings.TrimSpace(runtime.Str("end"))
if start == "" && end == "" {
return "", "", nil
}
var startTime, endTime string
if start != "" {
parsed, err := toRFC3339(start)
if err != nil {
return "", "", output.ErrValidation("--start: %v", err)
}
startTime = parsed
}
if end != "" {
parsed, err := toRFC3339(end, "end")
if err != nil {
return "", "", output.ErrValidation("--end: %v", err)
}
endTime = parsed
}
if startTime != "" && endTime != "" {
st, err := time.Parse(time.RFC3339, startTime)
if err != nil {
return "", "", fmt.Errorf("parse normalized --start: %w", err)
}
et, err := time.Parse(time.RFC3339, endTime)
if err != nil {
return "", "", fmt.Errorf("parse normalized --end: %w", err)
}
if st.After(et) {
return "", "", output.ErrValidation("--start (%s) is after --end (%s)", start, end)
}
}
return startTime, endTime, nil
}
// toRFC3339 converts a supported CLI time input into an RFC3339 timestamp.
func toRFC3339(input string, hint ...string) (string, error) {
ts, err := common.ParseTime(input, hint...)
if err != nil {
return "", err
}
sec, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return "", fmt.Errorf("invalid timestamp %q: %w", ts, err)
}
return time.Unix(sec, 0).Format(time.RFC3339), nil
}
// resolveUserIDs expands special user identifiers and removes duplicates.
func resolveUserIDs(flagName string, ids []string, runtime *common.RuntimeContext) ([]string, error) {
if len(ids) == 0 {
return nil, nil
}
currentUserID := runtime.UserOpenId()
seen := make(map[string]struct{}, len(ids))
out := make([]string, 0, len(ids))
for _, id := range ids {
if strings.EqualFold(id, "me") {
if currentUserID == "" {
return nil, output.ErrValidation("%s: \"me\" requires a logged-in user with a resolvable open_id", flagName)
}
id = currentUserID
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out, nil
}
// buildTimeFilter builds the create_time filter block for the API request.
func buildTimeFilter(startTime, endTime string) map[string]interface{} {
if startTime == "" && endTime == "" {
return nil
}
timeRange := map[string]interface{}{}
if startTime != "" {
timeRange["start_time"] = startTime
}
if endTime != "" {
timeRange["end_time"] = endTime
}
return timeRange
}
// buildMinutesSearchFilter builds the filter object for the API request body.
func buildMinutesSearchFilter(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
filter := map[string]interface{}{}
ownerIDs, err := resolveUserIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
if err != nil {
return nil, err
}
if len(ownerIDs) > 0 {
filter["owner_ids"] = ownerIDs
}
participantIDs, err := resolveUserIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
if err != nil {
return nil, err
}
if len(participantIDs) > 0 {
filter["participant_ids"] = participantIDs
}
if timeRange := buildTimeFilter(startTime, endTime); timeRange != nil {
filter["create_time"] = timeRange
}
if len(filter) == 0 {
return nil, nil
}
return filter, nil
}
// buildMinutesSearchBody builds the POST body for the minutes search API.
func buildMinutesSearchBody(runtime *common.RuntimeContext, startTime, endTime string) (map[string]interface{}, error) {
body := map[string]interface{}{}
if q := strings.TrimSpace(runtime.Str("query")); q != "" {
body["query"] = q
}
filter, err := buildMinutesSearchFilter(runtime, startTime, endTime)
if err != nil {
return nil, err
}
if filter != nil {
body["filter"] = filter
}
return body, nil
}
// buildMinutesSearchParams builds the query parameters for the search request.
func buildMinutesSearchParams(runtime *common.RuntimeContext) map[string]interface{} {
params := map[string]interface{}{}
pageSize := strings.TrimSpace(runtime.Str("page-size"))
if pageSize == "" {
pageSize = fmt.Sprintf("%d", defaultMinutesSearchPageSize)
}
params["page_size"] = pageSize
if pageToken := strings.TrimSpace(runtime.Str("page-token")); pageToken != "" {
params["page_token"] = pageToken
}
return params
}
// minuteSearchItems extracts the result items from the API response payload.
func minuteSearchItems(data map[string]interface{}) []interface{} {
return common.GetSlice(data, "items")
}
// minuteSearchToken extracts the minute token from a search result item.
func minuteSearchToken(item map[string]interface{}) string {
return common.GetString(item, "token")
}
// minuteSearchDisplayInfo extracts the display_info field from a search result item.
func minuteSearchDisplayInfo(item map[string]interface{}) string {
return common.GetString(item, "display_info")
}
// minuteSearchDescription extracts the description field from a search result item.
func minuteSearchDescription(item map[string]interface{}) string {
meta := common.GetMap(item, "meta_data")
return common.GetString(meta, "description")
}
// minuteSearchAppLink extracts the app link from a search result item.
func minuteSearchAppLink(item map[string]interface{}) string {
meta := common.GetMap(item, "meta_data")
return common.GetString(meta, "app_link")
}
// minuteSearchAvatar extracts the avatar URL from a search result item.
func minuteSearchAvatar(item map[string]interface{}) string {
meta := common.GetMap(item, "meta_data")
return common.GetString(meta, "avatar")
}
// buildMinuteSearchRows converts API items into pretty output rows.
func buildMinuteSearchRows(items []interface{}) []map[string]interface{} {
rows := make([]map[string]interface{}, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
rows = append(rows, map[string]interface{}{
"token": minuteSearchToken(item),
"display_info": common.TruncateStr(minuteSearchDisplayInfo(item), 40),
"description": common.TruncateStr(minuteSearchDescription(item), 40),
"app_link": common.TruncateStr(minuteSearchAppLink(item), 80),
"avatar": common.TruncateStr(minuteSearchAvatar(item), 80),
})
}
return rows
}
// MinutesSearch searches minutes by keyword, owners, participants, and time range.
var MinutesSearch = common.Shortcut{
Service: "minutes",
Command: "+search",
Description: "Search minutes by keyword, owners, participants, and time range",
Risk: "read",
Scopes: []string{"minutes:minutes.search:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword"},
{Name: "owner-ids", Desc: "owner open_id list, comma-separated (use \"me\" for current user)"},
{Name: "participant-ids", Desc: "participant open_id list, comma-separated (use \"me\" for current user)"},
{Name: "start", Desc: "time lower bound (ISO 8601 or YYYY-MM-DD)"},
{Name: "end", Desc: "time upper bound (ISO 8601 or YYYY-MM-DD)"},
{Name: "page-token", Desc: "page token for next page"},
{Name: "page-size", Default: "15", Desc: "page size, 1-30 (default 15)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, _, err := parseTimeRange(runtime); err != nil {
return err
}
if q := strings.TrimSpace(runtime.Str("query")); q != "" && utf8.RuneCountInString(q) > maxMinutesSearchQueryLen {
return output.ErrValidation("--query: length must be between 1 and 50 characters")
}
if _, err := common.ValidatePageSize(runtime, "page-size", defaultMinutesSearchPageSize, 1, maxMinutesSearchPageSize); err != nil {
return err
}
ownerIDs, err := resolveUserIDs("--owner-ids", common.SplitCSV(runtime.Str("owner-ids")), runtime)
if err != nil {
return err
}
for _, id := range ownerIDs {
if _, err := common.ValidateUserID(id); err != nil {
return err
}
}
participantIDs, err := resolveUserIDs("--participant-ids", common.SplitCSV(runtime.Str("participant-ids")), runtime)
if err != nil {
return err
}
for _, id := range participantIDs {
if _, err := common.ValidateUserID(id); err != nil {
return err
}
}
for _, flag := range []string{"query", "owner-ids", "participant-ids", "start", "end"} {
if strings.TrimSpace(runtime.Str(flag)) != "" {
return nil
}
}
return common.FlagErrorf("specify at least one of --query, --owner-ids, --participant-ids, --start, or --end")
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
startTime, endTime, err := parseTimeRange(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
params := buildMinutesSearchParams(runtime)
body, err := buildMinutesSearchBody(runtime, startTime, endTime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
dryRun := common.NewDryRunAPI().
POST("/open-apis/minutes/v1/minutes/search")
if len(params) > 0 {
dryRun.Params(params)
}
return dryRun.Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
startTime, endTime, err := parseTimeRange(runtime)
if err != nil {
return err
}
body, err := buildMinutesSearchBody(runtime, startTime, endTime)
if err != nil {
return err
}
data, err := runtime.CallAPI(http.MethodPost, "/open-apis/minutes/v1/minutes/search", buildMinutesSearchParams(runtime), body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
items := minuteSearchItems(data)
hasMore, _ := data["has_more"].(bool)
pageToken, _ := data["page_token"].(string)
rows := buildMinuteSearchRows(items)
outData := map[string]interface{}{
"items": items,
"total": data["total"],
"has_more": data["has_more"],
"page_token": data["page_token"],
}
runtime.OutFormat(outData, &output.Meta{Count: len(rows)}, func(w io.Writer) {
if len(rows) == 0 {
fmt.Fprintln(w, "No minutes.")
return
}
output.PrintTable(w, rows)
})
if hasMore && runtime.Format != "json" && runtime.Format != "" {
fmt.Fprintf(runtime.IO().Out, "\n(more available, page_token: %s)\n", pageToken)
}
return nil
},
}

View File

@@ -0,0 +1,691 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// newMinutesSearchTestCommand builds a command with the flags used by minutes search tests.
func newMinutesSearchTestCommand() *cobra.Command {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("owner-ids", "", "")
cmd.Flags().String("participant-ids", "", "")
cmd.Flags().String("start", "", "")
cmd.Flags().String("end", "", "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().String("page-size", "15", "")
return cmd
}
// configWithoutUserOpenID returns a test config without a resolvable user open_id.
func configWithoutUserOpenID() *core.CliConfig {
cfg := defaultConfig()
cfg.UserOpenId = ""
return cfg
}
// TestMinutesSearchParseTimeRange verifies valid time inputs are normalized.
func TestMinutesSearchParseTimeRange(t *testing.T) {
t.Parallel()
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("start", "2026-03-24")
_ = cmd.Flags().Set("end", "2026-03-25")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
start, end, err := parseTimeRange(runtime)
if err != nil {
t.Fatalf("parseTimeRange() unexpected error: %v", err)
}
if start == "" || end == "" {
t.Fatalf("expected non-empty start/end, got %q %q", start, end)
}
}
// TestMinutesSearchParseTimeRangeErrors verifies invalid time inputs return validation errors.
func TestMinutesSearchParseTimeRangeErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
start string
end string
wantMessage string
}{
{name: "invalid start", start: "bad-start", wantMessage: "--start:"},
{name: "invalid end", end: "bad-end", wantMessage: "--end:"},
{name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cmd := newMinutesSearchTestCommand()
if tt.start != "" {
_ = cmd.Flags().Set("start", tt.start)
}
if tt.end != "" {
_ = cmd.Flags().Set("end", tt.end)
}
_, _, err := parseTimeRange(common.TestNewRuntimeContext(cmd, defaultConfig()))
if err == nil {
t.Fatal("expected parseTimeRange error")
}
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
}
})
}
}
// TestBuildMinutesSearchParams verifies request params and body fields are assembled correctly.
func TestBuildMinutesSearchParams(t *testing.T) {
t.Parallel()
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("query", "budget")
_ = cmd.Flags().Set("owner-ids", "ou_owner,ou_owner_2")
_ = cmd.Flags().Set("participant-ids", "ou_c")
_ = cmd.Flags().Set("page-size", "5")
_ = cmd.Flags().Set("page-token", "next_page")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
params := buildMinutesSearchParams(runtime)
body, err := buildMinutesSearchBody(runtime, "2026-03-24T00:00:00Z", "2026-03-25T00:00:00Z")
if err != nil {
t.Fatalf("buildMinutesSearchBody() unexpected error: %v", err)
}
if got, _ := params["page_size"].(string); got != "5" {
t.Fatalf("page_size = %q, want 5", got)
}
if got, _ := params["page_token"].(string); got != "next_page" {
t.Fatalf("page_token = %q, want next_page", got)
}
if body["query"] != "budget" {
t.Fatalf("body.query = %v, want budget", body["query"])
}
filter, _ := body["filter"].(map[string]interface{})
if filter == nil {
t.Fatalf("body.filter = nil, want filter object")
}
owners, _ := filter["owner_ids"].([]string)
if len(owners) != 2 || owners[0] != "ou_owner" || owners[1] != "ou_owner_2" {
t.Fatalf("owner_ids = %v, want [ou_owner ou_owner_2]", filter["owner_ids"])
}
participants, _ := filter["participant_ids"].([]string)
if len(participants) != 1 || participants[0] != "ou_c" {
t.Fatalf("participant_ids = %v, want [ou_c]", filter["participant_ids"])
}
createTime, _ := filter["create_time"].(map[string]interface{})
if createTime == nil {
t.Fatalf("create_time = nil, want time range")
}
if createTime["start_time"] != "2026-03-24T00:00:00Z" {
t.Fatalf("start_time = %v", createTime["start_time"])
}
if createTime["end_time"] != "2026-03-25T00:00:00Z" {
t.Fatalf("end_time = %v", createTime["end_time"])
}
}
// TestBuildMinutesSearchParamsDefaultPageSize verifies the default page size is applied.
func TestBuildMinutesSearchParamsDefaultPageSize(t *testing.T) {
t.Parallel()
cmd := newMinutesSearchTestCommand()
params := buildMinutesSearchParams(common.TestNewRuntimeContext(cmd, defaultConfig()))
if got, _ := params["page_size"].(string); got != "15" {
t.Fatalf("page_size = %q, want 15", got)
}
}
// TestResolveUserIDs verifies me expansion, deduplication, and nil handling.
func TestResolveUserIDs(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "test"}
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
got, err := resolveUserIDs("--owner-ids", []string{"me"}, runtime)
if err != nil {
t.Fatalf("resolveUserIDs([me]) unexpected error: %v", err)
}
if len(got) != 1 || got[0] != "ou_testuser" {
t.Fatalf("resolveUserIDs([me]) = %v, want [ou_testuser]", got)
}
got, err = resolveUserIDs("--owner-ids", []string{"ou_other", "me", "Me"}, runtime)
if err != nil {
t.Fatalf("resolveUserIDs([ou_other, me, Me]) unexpected error: %v", err)
}
if len(got) != 2 || got[0] != "ou_other" || got[1] != "ou_testuser" {
t.Fatalf("resolveUserIDs([ou_other, me, Me]) = %v, want [ou_other ou_testuser]", got)
}
got, err = resolveUserIDs("--owner-ids", nil, runtime)
if err != nil {
t.Fatalf("resolveUserIDs(nil) unexpected error: %v", err)
}
if got != nil {
t.Fatalf("resolveUserIDs(nil) = %v, want nil", got)
}
}
// TestBuildTimeFilter verifies time filters are only populated for provided bounds.
func TestBuildTimeFilter(t *testing.T) {
t.Parallel()
if got := buildTimeFilter("", ""); got != nil {
t.Fatalf("buildTimeFilter('', '') = %v, want nil", got)
}
if got := buildTimeFilter("2026-03-24T00:00:00Z", ""); got["start_time"] != "2026-03-24T00:00:00Z" {
t.Fatalf("start_time = %v", got["start_time"])
}
if got := buildTimeFilter("", "2026-03-25T00:00:00Z"); got["end_time"] != "2026-03-25T00:00:00Z" {
t.Fatalf("end_time = %v", got["end_time"])
}
}
// TestMinutesSearchValidationMeOwnerID verifies owner-ids accepts me when open_id is available.
func TestMinutesSearchValidationMeOwnerID(t *testing.T) {
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("owner-ids", "me")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := MinutesSearch.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no error for --owner-ids me, got: %v", err)
}
}
// TestMinutesSearchValidationMeRequiresResolvableUser verifies me fails without a resolvable open_id.
func TestMinutesSearchValidationMeRequiresResolvableUser(t *testing.T) {
t.Parallel()
tests := []struct {
name string
flag string
}{
{name: "owner ids", flag: "owner-ids"},
{name: "participant ids", flag: "participant-ids"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set(tt.flag, "me")
runtime := common.TestNewRuntimeContext(cmd, configWithoutUserOpenID())
err := MinutesSearch.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error for unresolved me")
}
if !strings.Contains(err.Error(), "resolvable open_id") {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
// TestBuildMinutesSearchFilterMeExpansion verifies me is expanded inside the request filter.
func TestBuildMinutesSearchFilterMeExpansion(t *testing.T) {
t.Parallel()
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("owner-ids", "me,ou_other")
_ = cmd.Flags().Set("participant-ids", "me")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
body, err := buildMinutesSearchBody(runtime, "", "")
if err != nil {
t.Fatalf("buildMinutesSearchBody() unexpected error: %v", err)
}
filter, _ := body["filter"].(map[string]interface{})
if filter == nil {
t.Fatal("body.filter = nil, want filter object")
}
owners, _ := filter["owner_ids"].([]string)
if len(owners) != 2 || owners[0] != "ou_testuser" || owners[1] != "ou_other" {
t.Fatalf("owner_ids = %v, want [ou_testuser ou_other]", owners)
}
participants, _ := filter["participant_ids"].([]string)
if len(participants) != 1 || participants[0] != "ou_testuser" {
t.Fatalf("participant_ids = %v, want [ou_testuser]", participants)
}
}
// TestMinuteSearchItems verifies items extraction from the search response payload.
func TestMinuteSearchItems(t *testing.T) {
t.Parallel()
items := minuteSearchItems(map[string]interface{}{
"items": []interface{}{map[string]interface{}{"minute_token": "tok_1"}},
})
if len(items) != 1 {
t.Fatalf("minuteSearchItems() len = %d, want 1", len(items))
}
if got := minuteSearchItems(map[string]interface{}{}); got != nil {
t.Fatalf("minuteSearchItems() = %v, want nil", got)
}
}
// TestMinutesSearchValidationNoFilter verifies at least one filter is required.
func TestMinutesSearchValidationNoFilter(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, MinutesSearch, []string{"+search", "--as", "user"}, f, nil)
if err == nil {
t.Fatal("expected validation error for empty filters")
}
if !strings.Contains(err.Error(), "specify at least one") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestMinutesSearchValidationInvalidParticipantID verifies participant IDs must be valid open_ids.
func TestMinutesSearchValidationInvalidParticipantID(t *testing.T) {
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("participant-ids", "user_123")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := MinutesSearch.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected invalid user ID error")
}
}
// TestMinutesSearchValidationInvalidOwnerID verifies owner IDs must be valid open_ids.
func TestMinutesSearchValidationInvalidOwnerID(t *testing.T) {
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("owner-ids", "user_123")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := MinutesSearch.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected invalid owner ID error")
}
}
// TestMinutesSearchValidationQueryTooLong verifies overly long queries are rejected.
func TestMinutesSearchValidationQueryTooLong(t *testing.T) {
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("query", strings.Repeat("a", 51))
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := MinutesSearch.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected query length error")
}
if !strings.Contains(err.Error(), "length must be between 1 and 50 characters") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestMinutesSearchValidationMaxPageSize30 verifies the maximum allowed page size passes validation.
func TestMinutesSearchValidationMaxPageSize30(t *testing.T) {
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("query", "budget")
_ = cmd.Flags().Set("page-size", "30")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := MinutesSearch.Validate(context.Background(), runtime)
if err != nil {
t.Fatalf("expected no error for --page-size 30, got: %v", err)
}
}
// TestMinutesSearchValidationPageSizeAboveMax verifies page sizes above the limit are rejected.
func TestMinutesSearchValidationPageSizeAboveMax(t *testing.T) {
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("query", "budget")
_ = cmd.Flags().Set("page-size", "31")
runtime := common.TestNewRuntimeContext(cmd, defaultConfig())
err := MinutesSearch.Validate(context.Background(), runtime)
if err == nil {
t.Fatal("expected validation error for --page-size 31")
}
if !strings.Contains(err.Error(), "--page-size") {
t.Fatalf("unexpected error: %v", err)
}
}
// TestMinutesSearchValidationTimeErrors verifies time parsing failures surface through validation.
func TestMinutesSearchValidationTimeErrors(t *testing.T) {
t.Parallel()
tests := []struct {
name string
start string
end string
wantMessage string
}{
{name: "invalid start", start: "bad-start", wantMessage: "--start:"},
{name: "invalid end", end: "bad-end", wantMessage: "--end:"},
{name: "start after end", start: "2026-03-26", end: "2026-03-25", wantMessage: "is after --end"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
cmd := newMinutesSearchTestCommand()
_ = cmd.Flags().Set("query", "budget")
if tt.start != "" {
_ = cmd.Flags().Set("start", tt.start)
}
if tt.end != "" {
_ = cmd.Flags().Set("end", tt.end)
}
err := MinutesSearch.Validate(context.Background(), common.TestNewRuntimeContext(cmd, defaultConfig()))
if err == nil {
t.Fatal("expected validation error")
}
if !strings.Contains(err.Error(), tt.wantMessage) {
t.Fatalf("error = %v, want %q", err, tt.wantMessage)
}
})
}
}
// TestMinutesSearchDryRun verifies dry-run output includes the expected API request details.
func TestMinutesSearchDryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--owner-ids", "ou_owner,ou_owner_2", "--dry-run", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "/open-apis/minutes/v1/minutes/search") {
t.Fatalf("dry-run should show API path, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "\"method\": \"POST\"") {
t.Fatalf("dry-run should use POST, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "\"query\": \"budget\"") {
t.Fatalf("dry-run should show query in body, got: %s", stdout.String())
}
if !strings.Contains(stdout.String(), "\"owner_ids\": [") || !strings.Contains(stdout.String(), "\"ou_owner\"") {
t.Fatalf("dry-run should show owner_ids in filter, got: %s", stdout.String())
}
}
// TestMinutesSearchExecuteRendersRowsAndMoreHint verifies pretty output renders rows and pagination hints.
func TestMinutesSearchExecuteRendersRowsAndMoreHint(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
searchStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/minutes/v1/minutes/search",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"token": "minute_1",
"display_info": "周会摘要",
"meta_data": map[string]interface{}{
"description": "周会纪要",
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
},
},
},
"total": 1,
"has_more": true,
"page_token": "next_token",
},
},
}
reg.Register(searchStub)
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--owner-ids", "me", "--format", "pretty", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
var body map[string]interface{}
if err := json.Unmarshal(searchStub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal request body: %v", err)
}
if body["query"] != "budget" {
t.Fatalf("request query = %v, want budget", body["query"])
}
filter, _ := body["filter"].(map[string]interface{})
if filter == nil {
t.Fatalf("request filter = %v, want object", body["filter"])
}
owners, _ := filter["owner_ids"].([]interface{})
if len(owners) != 1 || owners[0] != "ou_testuser" {
t.Fatalf("request owner_ids = %v, want [ou_testuser]", filter["owner_ids"])
}
out := stdout.String()
for _, want := range []string{"minute_1", "周会摘要", "周会纪要", "https://meetings.feishu.cn/minutes/obcn123", "https://p3-lark-file.byteimg.com/img/xxxx.jpg", "next_token", "more available"} {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q, got: %s", want, out)
}
}
}
// TestMinutesSearchExecuteNoMinutes verifies empty results render the no-data message.
func TestMinutesSearchExecuteNoMinutes(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/minutes/v1/minutes/search",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{},
"total": 0,
"has_more": false,
"page_token": "",
},
},
})
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--format", "pretty", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
if !strings.Contains(stdout.String(), "No minutes.") {
t.Fatalf("expected no minutes message, got: %s", stdout.String())
}
}
// TestMinutesSearchExecuteShowsPaginationHintForTableFormat verifies table output includes pagination hints.
func TestMinutesSearchExecuteShowsPaginationHintForTableFormat(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/minutes/v1/minutes/search",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
map[string]interface{}{
"token": "minute_1",
"display_info": "周会摘要",
"meta_data": map[string]interface{}{
"description": "周会纪要",
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
},
},
},
"total": 1,
"has_more": true,
"page_token": "next_token",
},
},
})
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--format", "table", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
out := stdout.String()
if !strings.Contains(out, "next_token") || !strings.Contains(out, "more available") {
t.Fatalf("expected pagination hint in table output, got: %s", out)
}
}
// TestMinutesSearchExecuteJSONCountUsesRenderedRows verifies JSON metadata counts rendered rows only.
func TestMinutesSearchExecuteJSONCountUsesRenderedRows(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/minutes/v1/minutes/search",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"items": []interface{}{
nil,
map[string]interface{}{
"token": "minute_1",
"display_info": "周会摘要",
"meta_data": map[string]interface{}{
"description": "周会纪要",
},
},
},
"total": 2,
"has_more": false,
"page_token": "",
},
},
})
err := mountAndRun(t, MinutesSearch, []string{"+search", "--query", "budget", "--as", "user"}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
reg.Verify(t)
var envelope struct {
Meta struct {
Count int `json:"count"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failed to parse output: %v\nraw: %s", err, stdout.String())
}
if envelope.Meta.Count != 1 {
t.Fatalf("meta.count = %d, want 1", envelope.Meta.Count)
}
}
// TestMinuteSearchFieldExtractors verifies field extractors read populated metadata correctly.
func TestMinuteSearchFieldExtractors(t *testing.T) {
t.Parallel()
item := map[string]interface{}{
"token": "minute_1",
"display_info": "<h>周会</h>摘要",
"meta_data": map[string]interface{}{
"description": "周会纪要",
"app_link": "https://meetings.feishu.cn/minutes/obcn123",
"avatar": "https://p3-lark-file.byteimg.com/img/xxxx.jpg",
},
}
if got := minuteSearchToken(item); got != "minute_1" {
t.Fatalf("minuteSearchToken() = %q, want minute_1", got)
}
if got := minuteSearchDisplayInfo(item); got != "<h>周会</h>摘要" {
t.Fatalf("minuteSearchDisplayInfo() = %q", got)
}
if got := minuteSearchDescription(item); got != "周会纪要" {
t.Fatalf("minuteSearchDescription() = %q, want 周会纪要", got)
}
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/obcn123" {
t.Fatalf("minuteSearchAppLink() = %q", got)
}
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/xxxx.jpg" {
t.Fatalf("minuteSearchAvatar() = %q", got)
}
}
// TestMinuteSearchFieldExtractorsFallbacks verifies extractors keep working for alternate sample data.
func TestMinuteSearchFieldExtractorsFallbacks(t *testing.T) {
t.Parallel()
item := map[string]interface{}{
"token": "minute_2",
"display_info": "回退摘要",
"meta_data": map[string]interface{}{
"description": "回退纪要",
"app_link": "https://meetings.feishu.cn/minutes/fallback",
"avatar": "https://p3-lark-file.byteimg.com/img/fallback.jpg",
},
}
if got := minuteSearchToken(item); got != "minute_2" {
t.Fatalf("minuteSearchToken() = %q, want minute_2", got)
}
if got := minuteSearchDescription(item); got != "回退纪要" {
t.Fatalf("minuteSearchDescription() = %q, want 回退纪要", got)
}
if got := minuteSearchAppLink(item); got != "https://meetings.feishu.cn/minutes/fallback" {
t.Fatalf("minuteSearchAppLink() = %q", got)
}
if got := minuteSearchAvatar(item); got != "https://p3-lark-file.byteimg.com/img/fallback.jpg" {
t.Fatalf("minuteSearchAvatar() = %q", got)
}
}
// TestMinuteSearchFieldExtractorsMissingMetaData verifies extractors fall back to empty values without metadata.
func TestMinuteSearchFieldExtractorsMissingMetaData(t *testing.T) {
t.Parallel()
item := map[string]interface{}{
"token": "minute_3",
"display_info": "无元信息摘要",
}
if got := minuteSearchToken(item); got != "minute_3" {
t.Fatalf("minuteSearchToken() = %q, want minute_3", got)
}
if got := minuteSearchDescription(item); got != "" {
t.Fatalf("minuteSearchDescription() = %q, want empty", got)
}
if got := minuteSearchAppLink(item); got != "" {
t.Fatalf("minuteSearchAppLink() = %q, want empty", got)
}
if got := minuteSearchAvatar(item); got != "" {
t.Fatalf("minuteSearchAvatar() = %q, want empty", got)
}
}

View File

@@ -8,6 +8,7 @@ import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all minutes shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
MinutesSearch,
MinutesDownload,
}
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/larksuite/cli/shortcuts/mail"
"github.com/larksuite/cli/shortcuts/minutes"
"github.com/larksuite/cli/shortcuts/sheets"
"github.com/larksuite/cli/shortcuts/slides"
"github.com/larksuite/cli/shortcuts/task"
"github.com/larksuite/cli/shortcuts/vc"
"github.com/larksuite/cli/shortcuts/whiteboard"
@@ -38,6 +39,7 @@ func init() {
allShortcuts = append(allShortcuts, base.Shortcuts()...)
allShortcuts = append(allShortcuts, event.Shortcuts()...)
allShortcuts = append(allShortcuts, mail.Shortcuts()...)
allShortcuts = append(allShortcuts, slides.Shortcuts()...)
allShortcuts = append(allShortcuts, minutes.Shortcuts()...)
allShortcuts = append(allShortcuts, task.Shortcuts()...)
allShortcuts = append(allShortcuts, vc.Shortcuts()...)

View File

@@ -0,0 +1,81 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetAddDimension = common.Shortcut{
Service: "sheets",
Command: "+add-dimension",
Description: "Add rows or columns at the end of a sheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "length", Type: "int", Desc: "number of rows/columns to add (1-5000)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
length := runtime.Int("length")
if length < 1 || length > 5000 {
return common.FlagErrorf("--length must be between 1 and 5000, got %d", length)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
Body(map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"length": runtime.Int("length"),
},
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"length": runtime.Int("length"),
},
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetBatchSetStyle = common.Shortcut{
Service: "sheets",
Command: "+batch-set-style",
Description: "Batch set cell styles for multiple ranges",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "data", Desc: "JSON array of {ranges, style} objects", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
var data interface{}
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
return common.FlagErrorf("--data must be valid JSON: %v", err)
}
arr, ok := data.([]interface{})
if !ok || len(arr) == 0 {
return common.FlagErrorf("--data must be a non-empty JSON array")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
var data interface{}
json.Unmarshal([]byte(runtime.Str("data")), &data)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/styles_batch_update").
Body(map[string]interface{}{
"data": data,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
var data interface{}
if err := json.Unmarshal([]byte(runtime.Str("data")), &data); err != nil {
return common.FlagErrorf("--data must be valid JSON: %v", err)
}
result, err := runtime.CallAPI("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/styles_batch_update", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"data": data,
},
)
if err != nil {
return err
}
runtime.Out(result, nil)
return nil
},
}

View File

@@ -0,0 +1,539 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── MergeCells ───────────────────────────────────────────────────────────────
func TestSheetMergeCellsValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL",
}, nil)
err := SheetMergeCells.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetMergeCellsValidateRelativeRangeWithoutSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "A1:B2", "sheet-id": "", "merge-type": "MERGE_ALL",
}, nil)
err := SheetMergeCells.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--sheet-id") {
t.Fatalf("expected sheet-id error, got: %v", err)
}
}
func TestSheetMergeCellsValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "", "merge-type": "MERGE_ROWS",
}, nil)
if err := SheetMergeCells.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetMergeCellsDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1", "merge-type": "MERGE_ALL",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetMergeCells.DryRun(context.Background(), rt))
if !strings.Contains(got, `merge_cells`) {
t.Fatalf("DryRun URL missing merge_cells: %s", got)
}
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
t.Fatalf("DryRun range not normalized: %s", got)
}
if !strings.Contains(got, `"mergeType":"MERGE_ALL"`) {
t.Fatalf("DryRun missing mergeType: %s", got)
}
}
func TestSheetMergeCellsExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}},
})
err := mountAndRunSheets(t, SheetMergeCells, []string{
"+merge-cells", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "spreadsheetToken") {
t.Fatalf("stdout missing spreadsheetToken: %s", stdout.String())
}
}
func TestSheetMergeCellsExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/merge_cells",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetMergeCells, []string{
"+merge-cells", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--merge-type", "MERGE_ALL", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── UnmergeCells ─────────────────────────────────────────────────────────────
func TestSheetUnmergeCellsValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "",
}, nil)
err := SheetUnmergeCells.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetUnmergeCellsValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
}, nil)
if err := SheetUnmergeCells.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetUnmergeCellsDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "sheet1!A1:B2", "sheet-id": "",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetUnmergeCells.DryRun(context.Background(), rt))
if !strings.Contains(got, `unmerge_cells`) {
t.Fatalf("DryRun URL missing unmerge_cells: %s", got)
}
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
t.Fatalf("DryRun missing range: %s", got)
}
}
func TestSheetUnmergeCellsExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"spreadsheetToken": "shtTOKEN"}},
})
err := mountAndRunSheets(t, SheetUnmergeCells, []string{
"+unmerge-cells", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetUnmergeCellsExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/unmerge_cells",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetUnmergeCells, []string{
"+unmerge-cells", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── Replace ──────────────────────────────────────────────────────────────────
func TestSheetReplaceValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "find": "a", "replacement": "b", "range": "",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
err := SheetReplace.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetReplaceValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "find": "hello", "replacement": "world", "range": "",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
if err := SheetReplace.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetReplaceValidateMismatchedRangeSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b",
"range": "sheet2!A1:B2",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
err := SheetReplace.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "does not match") {
t.Fatalf("expected mismatch error, got: %v", err)
}
}
func TestSheetReplaceValidateMatchingRangeSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "sheet1", "find": "a", "replacement": "b",
"range": "sheet1!A1:B2",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
if err := SheetReplace.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetReplaceDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "old", "replacement": "new", "range": "A1:C5",
}, map[string]bool{"match-case": true, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt))
if !strings.Contains(got, `replace`) {
t.Fatalf("DryRun URL missing replace: %s", got)
}
if !strings.Contains(got, `"find":"old"`) {
t.Fatalf("DryRun missing find: %s", got)
}
if !strings.Contains(got, `"replacement":"new"`) {
t.Fatalf("DryRun missing replacement: %s", got)
}
if !strings.Contains(got, `"match_case":true`) {
t.Fatalf("DryRun missing match_case: %s", got)
}
}
func TestSheetReplaceDryRunNoRange(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "find": "a", "replacement": "b", "range": "",
}, map[string]bool{"match-case": false, "match-entire-cell": false, "search-by-regex": false, "include-formulas": false})
got := mustMarshalSheetsDryRun(t, SheetReplace.DryRun(context.Background(), rt))
// When no range specified, range defaults to sheet-id
if !strings.Contains(got, `"range":"sheet1"`) {
t.Fatalf("DryRun range should default to sheet-id: %s", got)
}
}
func TestSheetReplaceExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"replace_result": map[string]interface{}{
"matched_cells": []interface{}{"A1"}, "rows_count": float64(1),
},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetReplace, []string{
"+replace", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--find", "hello", "--replacement", "world", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "matched_cells") {
t.Fatalf("stdout missing matched_cells: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
if body["find"] != "hello" || body["replacement"] != "world" {
t.Fatalf("unexpected body: %#v", body)
}
}
func TestSheetReplaceExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/replace",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetReplace, []string{
"+replace", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--find", "a", "--replacement", "b", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── SetStyle ─────────────────────────────────────────────────────────────────
func TestSheetSetStyleValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `{"font":{"bold":true}}`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetSetStyleValidateInvalidJSON(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `{invalid}`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--style must be valid JSON") {
t.Fatalf("expected JSON error, got: %v", err)
}
}
func TestSheetSetStyleValidateRejectsArray(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `[{"bold":true}]`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "JSON object") {
t.Fatalf("expected object error, got: %v", err)
}
}
func TestSheetSetStyleValidateRejectsString(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `"bold"`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "JSON object") {
t.Fatalf("expected object error, got: %v", err)
}
}
func TestSheetSetStyleValidateRejectsNull(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `null`,
}, nil)
err := SheetSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "JSON object") {
t.Fatalf("expected object error, got: %v", err)
}
}
func TestSheetSetStyleValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "sheet1!A1:B2", "sheet-id": "",
"style": `{"font":{"bold":true},"backColor":"#ff0000"}`,
}, nil)
if err := SheetSetStyle.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetSetStyleDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "A1:B2", "sheet-id": "sheet1",
"style": `{"font":{"bold":true}}`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetSetStyle.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
if !strings.Contains(got, `/style`) {
t.Fatalf("DryRun URL missing /style: %s", got)
}
if !strings.Contains(got, `"range":"sheet1!A1:B2"`) {
t.Fatalf("DryRun range not normalized: %s", got)
}
if !strings.Contains(got, `"bold":true`) {
t.Fatalf("DryRun missing style: %s", got)
}
}
func TestSheetSetStyleExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"updates": map[string]interface{}{"updatedCells": float64(4), "updatedRange": "sheet1!A1:B2"},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetSetStyle, []string{
"+set-style", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "updatedCells") {
t.Fatalf("stdout missing updatedCells: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
appendStyle, _ := body["appendStyle"].(map[string]interface{})
if appendStyle["range"] != "sheet1!A1:B2" {
t.Fatalf("unexpected range: %v", appendStyle["range"])
}
}
func TestSheetSetStyleExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/style",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetSetStyle, []string{
"+set-style", "--spreadsheet-token", "shtTOKEN",
"--range", "sheet1!A1:B2", "--style", `{"font":{"bold":true}}`, "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── BatchSetStyle ────────────────────────────────────────────────────────────
func TestSheetBatchSetStyleValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "",
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`,
}, nil)
err := SheetBatchSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetBatchSetStyleValidateInvalidJSON(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "data": `not-json`,
}, nil)
err := SheetBatchSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--data must be valid JSON") {
t.Fatalf("expected JSON error, got: %v", err)
}
}
func TestSheetBatchSetStyleValidateNotArray(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "data": `{"not":"array"}`,
}, nil)
err := SheetBatchSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") {
t.Fatalf("expected array error, got: %v", err)
}
}
func TestSheetBatchSetStyleValidateEmptyArray(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "data": `[]`,
}, nil)
err := SheetBatchSetStyle.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "non-empty JSON array") {
t.Fatalf("expected empty array error, got: %v", err)
}
}
func TestSheetBatchSetStyleValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`,
}, nil)
if err := SheetBatchSetStyle.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetBatchSetStyleDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test",
"data": `[{"ranges":["sheet1!A1:B2"],"style":{"backColor":"#ff0000"}}]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetBatchSetStyle.DryRun(context.Background(), rt))
if !strings.Contains(got, `styles_batch_update`) {
t.Fatalf("DryRun URL missing styles_batch_update: %s", got)
}
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
}
func TestSheetBatchSetStyleExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"totalUpdatedCells": float64(4), "revision": float64(90),
}},
})
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
"--data", `[{"ranges":["sheet1!A1:B2"],"style":{"font":{"bold":true}}}]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "totalUpdatedCells") {
t.Fatalf("stdout missing totalUpdatedCells: %s", stdout.String())
}
}
func TestSheetBatchSetStyleExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/styles_batch_update",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetBatchSetStyle, []string{
"+batch-set-style", "--spreadsheet-token", "shtTOKEN",
"--data", `[{"ranges":["sheet1!A1:B2"],"style":{}}]`, "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}

View File

@@ -0,0 +1,86 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetDeleteDimension = common.Shortcut{
Service: "sheets",
Command: "+delete-dimension",
Description: "Delete rows or columns",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if runtime.Int("start-index") < 1 {
return common.FlagErrorf("--start-index must be >= 1")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
Body(map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
data, err := runtime.CallAPI("DELETE",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,923 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"strconv"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// newDimTestRuntime creates a RuntimeContext with string, int, and bool flags.
func newDimTestRuntime(t *testing.T, strFlags map[string]string, intFlags map[string]int, boolFlags map[string]bool) *common.RuntimeContext {
t.Helper()
cmd := &cobra.Command{Use: "test"}
for name := range strFlags {
cmd.Flags().String(name, "", "")
}
for name := range intFlags {
cmd.Flags().Int(name, 0, "")
}
for name := range boolFlags {
cmd.Flags().Bool(name, false, "")
}
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags() error = %v", err)
}
for name, value := range strFlags {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, value := range intFlags {
if err := cmd.Flags().Set(name, strconv.Itoa(value)); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
for name, value := range boolFlags {
if err := cmd.Flags().Set(name, strconv.FormatBool(value)); err != nil {
t.Fatalf("Flags().Set(%q) error = %v", name, err)
}
}
return &common.RuntimeContext{Cmd: cmd}
}
func marshalDryRun(t *testing.T, v interface{}) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
return string(b)
}
// ── AddDimension ─────────────────────────────────────────────────────────────
func TestSheetAddDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"length": 10}, nil)
err := SheetAddDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetAddDimensionValidateLengthOutOfRange(t *testing.T) {
t.Parallel()
for _, length := range []int{0, -1, 5001} {
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"length": length}, nil)
err := SheetAddDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--length") {
t.Fatalf("length=%d: expected length error, got: %v", length, err)
}
}
}
func TestSheetAddDimensionValidateSuccess(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"length": 100}, nil)
if err := SheetAddDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetAddDimensionValidateWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"length": 5}, nil)
if err := SheetAddDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetAddDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"length": 8}, nil)
got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `dimension_range`) {
t.Fatalf("DryRun URL missing dimension_range: %s", got)
}
if !strings.Contains(got, `"sheetId":"sheet1"`) {
t.Fatalf("DryRun missing sheetId: %s", got)
}
if !strings.Contains(got, `"majorDimension":"ROWS"`) {
t.Fatalf("DryRun missing majorDimension: %s", got)
}
if !strings.Contains(got, `"length":8`) {
t.Fatalf("DryRun missing length: %s", got)
}
}
func TestSheetAddDimensionDryRunWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"length": 3}, nil)
got := marshalDryRun(t, SheetAddDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, "shtFromURL") {
t.Fatalf("DryRun should extract token from URL: %s", got)
}
}
func TestSheetAddDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Body: map[string]interface{}{
"code": 0, "msg": "Success",
"data": map[string]interface{}{"addCount": float64(8), "majorDimension": "ROWS"},
},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetAddDimension, []string{
"+add-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--length", "8",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"addCount"`) {
t.Fatalf("stdout missing addCount: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
dim, _ := body["dimension"].(map[string]interface{})
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
t.Fatalf("unexpected request body: %#v", body)
}
}
func TestSheetAddDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Status: 400,
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
})
err := mountAndRunSheets(t, SheetAddDimension, []string{
"+add-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--length", "8",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}
// ── InsertDimension ──────────────────────────────────────────────────────────
func TestSheetInsertDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
map[string]int{"start-index": 0, "end-index": 3}, nil)
err := SheetInsertDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetInsertDimensionValidateNegativeStartIndex(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
map[string]int{"start-index": -1, "end-index": 3}, nil)
err := SheetInsertDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--start-index") {
t.Fatalf("expected start-index error, got: %v", err)
}
}
func TestSheetInsertDimensionValidateEndNotGreaterThanStart(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS", "inherit-style": ""},
map[string]int{"start-index": 5, "end-index": 5}, nil)
err := SheetInsertDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--end-index") {
t.Fatalf("expected end-index error, got: %v", err)
}
}
func TestSheetInsertDimensionValidateSuccess(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS", "inherit-style": ""},
map[string]int{"start-index": 0, "end-index": 4}, nil)
if err := SheetInsertDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetInsertDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS", "inherit-style": "BEFORE"},
map[string]int{"start-index": 3, "end-index": 7}, nil)
got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `insert_dimension_range`) {
t.Fatalf("DryRun URL missing insert_dimension_range: %s", got)
}
if !strings.Contains(got, `"startIndex":3`) {
t.Fatalf("DryRun missing startIndex: %s", got)
}
if !strings.Contains(got, `"endIndex":7`) {
t.Fatalf("DryRun missing endIndex: %s", got)
}
if !strings.Contains(got, `"inheritStyle":"BEFORE"`) {
t.Fatalf("DryRun missing inheritStyle: %s", got)
}
}
func TestSheetInsertDimensionDryRunNoInheritStyle(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "COLUMNS", "inherit-style": ""},
map[string]int{"start-index": 0, "end-index": 2}, nil)
got := marshalDryRun(t, SheetInsertDimension.DryRun(context.Background(), rt))
if strings.Contains(got, `inheritStyle`) {
t.Fatalf("DryRun should omit inheritStyle when empty: %s", got)
}
}
func TestSheetInsertDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetInsertDimension, []string{
"+insert-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "3",
"--end-index", "7",
"--inherit-style", "AFTER",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
dim, _ := body["dimension"].(map[string]interface{})
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
t.Fatalf("unexpected dimension: %#v", dim)
}
if body["inheritStyle"] != "AFTER" {
t.Fatalf("unexpected inheritStyle: %v", body["inheritStyle"])
}
}
func TestSheetInsertDimensionExecuteWithoutInheritStyle(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetInsertDimension, []string{
"+insert-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "COLUMNS",
"--start-index", "0",
"--end-index", "2",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
if _, ok := body["inheritStyle"]; ok {
t.Fatalf("inheritStyle should be absent when not specified: %#v", body)
}
}
func TestSheetInsertDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/insert_dimension_range",
Status: 400,
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
})
err := mountAndRunSheets(t, SheetInsertDimension, []string{
"+insert-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "0",
"--end-index", "3",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}
// ── UpdateDimension ──────────────────────────────────────────────────────────
func TestSheetUpdateDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50},
map[string]bool{"visible": true})
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateStartIndexLessThan1(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 3, "fixed-size": 50},
map[string]bool{"visible": true})
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--start-index") {
t.Fatalf("expected start-index error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateEndLessThanStart(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 5, "end-index": 3, "fixed-size": 50},
map[string]bool{"visible": true})
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--end-index") {
t.Fatalf("expected end-index error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateNoProperties(t *testing.T) {
t.Parallel()
// Neither --visible nor --fixed-size is set (Changed returns false)
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3}, nil)
// Register the flags but don't set them so Changed() returns false
rt.Cmd.Flags().Bool("visible", false, "")
rt.Cmd.Flags().Int("fixed-size", 0, "")
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--visible or --fixed-size") {
t.Fatalf("expected properties error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateSuccessWithVisible(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3},
map[string]bool{"visible": true})
// Ensure fixed-size flag exists but is not set
rt.Cmd.Flags().Int("fixed-size", 0, "")
if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetUpdateDimensionValidateFixedSizeZero(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 0}, nil)
rt.Cmd.Flags().Bool("visible", false, "")
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") {
t.Fatalf("expected fixed-size error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateFixedSizeNegative(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": -10}, nil)
rt.Cmd.Flags().Bool("visible", false, "")
err := SheetUpdateDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--fixed-size must be >= 1") {
t.Fatalf("expected fixed-size error, got: %v", err)
}
}
func TestSheetUpdateDimensionValidateSuccessWithFixedSize(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"start-index": 1, "end-index": 5, "fixed-size": 120}, nil)
// Ensure visible flag exists but is not set
rt.Cmd.Flags().Bool("visible", false, "")
if err := SheetUpdateDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetUpdateDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3, "fixed-size": 50},
map[string]bool{"visible": true})
got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
if !strings.Contains(got, `dimension_range`) {
t.Fatalf("DryRun URL missing dimension_range: %s", got)
}
if !strings.Contains(got, `"visible":true`) {
t.Fatalf("DryRun missing visible: %s", got)
}
if !strings.Contains(got, `"fixedSize":50`) {
t.Fatalf("DryRun missing fixedSize: %s", got)
}
}
func TestSheetUpdateDimensionDryRunOnlyVisible(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3},
map[string]bool{"visible": false})
// Add fixed-size flag but don't set it
rt.Cmd.Flags().Int("fixed-size", 0, "")
got := marshalDryRun(t, SheetUpdateDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `"visible":false`) {
t.Fatalf("DryRun missing visible: %s", got)
}
if strings.Contains(got, `fixedSize`) {
t.Fatalf("DryRun should omit fixedSize when not set: %s", got)
}
}
func TestSheetUpdateDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetUpdateDimension, []string{
"+update-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "1",
"--end-index", "3",
"--visible=true",
"--fixed-size", "50",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
props, _ := body["dimensionProperties"].(map[string]interface{})
if props["visible"] != true {
t.Fatalf("expected visible=true, got: %#v", props)
}
if props["fixedSize"] != float64(50) {
t.Fatalf("expected fixedSize=50, got: %#v", props)
}
}
func TestSheetUpdateDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Status: 400,
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
})
err := mountAndRunSheets(t, SheetUpdateDimension, []string{
"+update-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "1",
"--end-index", "3",
"--visible=true",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}
// ── MoveDimension ────────────────────────────────────────────────────────────
func TestSheetMoveDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
err := SheetMoveDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetMoveDimensionValidateNegativeStartIndex(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": -1, "end-index": 1, "destination-index": 4}, nil)
err := SheetMoveDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--start-index") {
t.Fatalf("expected start-index error, got: %v", err)
}
}
func TestSheetMoveDimensionValidateEndLessThanStart(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 5, "end-index": 3, "destination-index": 0}, nil)
err := SheetMoveDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--end-index") {
t.Fatalf("expected end-index error, got: %v", err)
}
}
func TestSheetMoveDimensionValidateNegativeDestinationIndex(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 1, "destination-index": -1}, nil)
err := SheetMoveDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--destination-index") {
t.Fatalf("expected destination-index error, got: %v", err)
}
}
func TestSheetMoveDimensionValidateSuccess(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"start-index": 0, "end-index": 2, "destination-index": 5}, nil)
if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetMoveDimensionValidateWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
if err := SheetMoveDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetMoveDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 1, "destination-index": 4}, nil)
got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `move_dimension`) {
t.Fatalf("DryRun URL missing move_dimension: %s", got)
}
if !strings.Contains(got, `"major_dimension":"ROWS"`) {
t.Fatalf("DryRun missing major_dimension: %s", got)
}
if !strings.Contains(got, `"start_index":0`) {
t.Fatalf("DryRun missing start_index: %s", got)
}
if !strings.Contains(got, `"destination_index":4`) {
t.Fatalf("DryRun missing destination_index: %s", got)
}
}
func TestSheetMoveDimensionDryRunWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"},
map[string]int{"start-index": 1, "end-index": 3, "destination-index": 0}, nil)
got := marshalDryRun(t, SheetMoveDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, "shtFromURL") {
t.Fatalf("DryRun should extract token from URL: %s", got)
}
}
func TestSheetMoveDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetMoveDimension, []string{
"+move-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "0",
"--end-index", "1",
"--destination-index", "4",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
source, _ := body["source"].(map[string]interface{})
if source["major_dimension"] != "ROWS" {
t.Fatalf("unexpected major_dimension: %v", source["major_dimension"])
}
if body["destination_index"] != float64(4) {
t.Fatalf("unexpected destination_index: %v", body["destination_index"])
}
}
func TestSheetMoveDimensionExecuteWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/move_dimension",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetMoveDimension, []string{
"+move-dimension",
"--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1",
"--dimension", "COLUMNS",
"--start-index", "1",
"--end-index", "2",
"--destination-index", "0",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetMoveDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/move_dimension",
Status: 400,
Body: map[string]interface{}{"code": 1310211, "msg": "wrong sheet id"},
})
err := mountAndRunSheets(t, SheetMoveDimension, []string{
"+move-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "0",
"--end-index", "1",
"--destination-index", "4",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}
// ── DeleteDimension ──────────────────────────────────────────────────────────
func TestSheetDeleteDimensionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 1, "end-index": 3}, nil)
err := SheetDeleteDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSheetDeleteDimensionValidateStartIndexLessThan1(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 0, "end-index": 3}, nil)
err := SheetDeleteDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--start-index") {
t.Fatalf("expected start-index error, got: %v", err)
}
}
func TestSheetDeleteDimensionValidateEndLessThanStart(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"start-index": 5, "end-index": 3}, nil)
err := SheetDeleteDimension.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--end-index") {
t.Fatalf("expected end-index error, got: %v", err)
}
}
func TestSheetDeleteDimensionValidateSuccess(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "dimension": "ROWS"},
map[string]int{"start-index": 3, "end-index": 7}, nil)
if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetDeleteDimensionValidateWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtABC", "spreadsheet-token": "", "sheet-id": "s1", "dimension": "COLUMNS"},
map[string]int{"start-index": 1, "end-index": 2}, nil)
if err := SheetDeleteDimension.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetDeleteDimensionDryRun(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "dimension": "ROWS"},
map[string]int{"start-index": 3, "end-index": 7}, nil)
got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
if !strings.Contains(got, `dimension_range`) {
t.Fatalf("DryRun URL missing dimension_range: %s", got)
}
if !strings.Contains(got, `"startIndex":3`) {
t.Fatalf("DryRun missing startIndex: %s", got)
}
if !strings.Contains(got, `"endIndex":7`) {
t.Fatalf("DryRun missing endIndex: %s", got)
}
}
func TestSheetDeleteDimensionDryRunWithURL(t *testing.T) {
t.Parallel()
rt := newDimTestRuntime(t,
map[string]string{"url": "https://example.feishu.cn/sheets/shtFromURL", "spreadsheet-token": "", "sheet-id": "sheet1", "dimension": "COLUMNS"},
map[string]int{"start-index": 1, "end-index": 5}, nil)
got := marshalDryRun(t, SheetDeleteDimension.DryRun(context.Background(), rt))
if !strings.Contains(got, "shtFromURL") {
t.Fatalf("DryRun should extract token from URL: %s", got)
}
}
func TestSheetDeleteDimensionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"delCount": float64(5), "majorDimension": "ROWS"},
},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
"+delete-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "3",
"--end-index", "7",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), `"delCount"`) {
t.Fatalf("stdout missing delCount: %s", stdout.String())
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse request body: %v", err)
}
dim, _ := body["dimension"].(map[string]interface{})
if dim["sheetId"] != "sheet1" || dim["majorDimension"] != "ROWS" {
t.Fatalf("unexpected dimension: %#v", dim)
}
}
func TestSheetDeleteDimensionExecuteWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dimension_range",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"delCount": float64(2), "majorDimension": "COLUMNS"},
},
})
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
"+delete-dimension",
"--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1",
"--dimension", "COLUMNS",
"--start-index", "1",
"--end-index", "2",
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSheetDeleteDimensionExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dimension_range",
Status: 400,
Body: map[string]interface{}{"code": 90001, "msg": "invalid request"},
})
err := mountAndRunSheets(t, SheetDeleteDimension, []string{
"+delete-dimension",
"--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1",
"--dimension", "ROWS",
"--start-index", "3",
"--end-index", "7",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected API error, got nil")
}
}

View File

@@ -0,0 +1,333 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func dataValidationBasePath(token string) string {
return fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dataValidation",
validate.EncodePathSegment(token))
}
func dataValidationSheetPath(token, sheetID string) string {
return fmt.Sprintf("%s/%s", dataValidationBasePath(token), validate.EncodePathSegment(sheetID))
}
func validateDropdownToken(runtime *common.RuntimeContext) (string, error) {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
func parseJSONStringArray(flagName, value string) ([]interface{}, error) {
var typed []string
if err := json.Unmarshal([]byte(value), &typed); err != nil {
return nil, common.FlagErrorf("--%s must be a JSON array of strings: %v", flagName, err)
}
if typed == nil {
return nil, common.FlagErrorf("--%s must be a JSON array, got null", flagName)
}
arr := make([]interface{}, len(typed))
for i, s := range typed {
arr[i] = s
}
return arr, nil
}
func validateRangesFlag(runtime *common.RuntimeContext) ([]interface{}, error) {
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return nil, err
}
if len(ranges) == 0 {
return nil, common.FlagErrorf("--ranges must not be empty")
}
for i, r := range ranges {
s, _ := r.(string)
if _, _, ok := splitSheetRange(s); !ok {
return nil, common.FlagErrorf("--ranges[%d] %q must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)", i, s)
}
}
return ranges, nil
}
func buildDropdownBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
condValues, err := parseJSONStringArray("condition-values", runtime.Str("condition-values"))
if err != nil {
return nil, err
}
if len(condValues) == 0 {
return nil, common.FlagErrorf("--condition-values must not be empty")
}
dv := map[string]interface{}{
"conditionValues": condValues,
}
opts := map[string]interface{}{}
if runtime.Cmd.Flags().Changed("multiple") {
opts["multipleValues"] = runtime.Bool("multiple")
}
if runtime.Cmd.Flags().Changed("highlight") {
opts["highlightValidData"] = runtime.Bool("highlight")
}
if runtime.Str("colors") != "" {
colors, err := parseJSONStringArray("colors", runtime.Str("colors"))
if err != nil {
return nil, err
}
if len(colors) != len(condValues) {
return nil, common.FlagErrorf("--colors length (%d) must match --condition-values length (%d)", len(colors), len(condValues))
}
opts["colors"] = colors
}
if len(opts) > 0 {
dv["options"] = opts
}
return dv, nil
}
// SheetSetDropdown sets dropdown list validation on a range.
var SheetSetDropdown = common.Shortcut{
Service: "sheets",
Command: "+set-dropdown",
Description: "Set dropdown list on a cell range",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]'), max 500, each <=100 chars, no commas`, Required: true},
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
{Name: "colors", Desc: `RGB hex color array (e.g. '["#1FB6C1","#F006C2"]'), must match condition-values length`},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
}
_, err := buildDropdownBody(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
dv, _ := buildDropdownBody(runtime)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
Body(map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
"dataValidation": dv,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
dv, err := buildDropdownBody(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPI("POST", dataValidationBasePath(token), nil,
map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
"dataValidation": dv,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetUpdateDropdown updates dropdown list settings for given ranges.
var SheetUpdateDropdown = common.Shortcut{
Service: "sheets",
Command: "+update-dropdown",
Description: "Update dropdown list settings",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A1:A100"]')`, Required: true},
{Name: "condition-values", Desc: `dropdown options as JSON array (e.g. '["opt1","opt2"]')`, Required: true},
{Name: "multiple", Desc: "enable multi-select (default false)", Type: "bool"},
{Name: "highlight", Desc: "color-code options (default false)", Type: "bool"},
{Name: "colors", Desc: `RGB hex color array, must match condition-values length`},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, err := validateRangesFlag(runtime); err != nil {
return err
}
_, err := buildDropdownBody(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
dv, _ := buildDropdownBody(runtime)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/dataValidation/:sheet_id").
Body(map[string]interface{}{
"ranges": ranges,
"dataValidationType": "list",
"dataValidation": dv,
}).
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return err
}
dv, err := buildDropdownBody(runtime)
if err != nil {
return err
}
data, err := runtime.CallAPI("PUT", dataValidationSheetPath(token, runtime.Str("sheet-id")), nil,
map[string]interface{}{
"ranges": ranges,
"dataValidationType": "list",
"dataValidation": dv,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetGetDropdown queries dropdown list settings for a range.
var SheetGetDropdown = common.Shortcut{
Service: "sheets",
Command: "+get-dropdown",
Description: "Get dropdown list settings for a range",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A2:A100)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
if _, _, ok := splitSheetRange(runtime.Str("range")); !ok {
return common.FlagErrorf("--range must be a fully qualified range with sheet ID prefix (e.g. <sheetId>!A2:A100)")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v2/spreadsheets/:token/dataValidation?range=:range&dataValidationType=list").
Set("token", token).Set("range", runtime.Str("range"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
data, err := runtime.CallAPI("GET", dataValidationBasePath(token),
map[string]interface{}{
"range": runtime.Str("range"),
"dataValidationType": "list",
}, nil,
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// SheetDeleteDropdown deletes dropdown list settings from given ranges.
var SheetDeleteDropdown = common.Shortcut{
Service: "sheets",
Command: "+delete-dropdown",
Description: "Delete dropdown list from cell ranges",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "ranges", Desc: `ranges as JSON array (e.g. '["sheetId!A2:A100"]'), max 100 ranges`, Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateDropdownToken(runtime); err != nil {
return err
}
_, err := validateRangesFlag(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateDropdownToken(runtime)
ranges, _ := parseJSONStringArray("ranges", runtime.Str("ranges"))
dvRanges := make([]interface{}, 0, len(ranges))
for _, r := range ranges {
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
}
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v2/spreadsheets/:token/dataValidation").
Body(map[string]interface{}{
"dataValidationRanges": dvRanges,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateDropdownToken(runtime)
ranges, err := parseJSONStringArray("ranges", runtime.Str("ranges"))
if err != nil {
return err
}
dvRanges := make([]interface{}, 0, len(ranges))
for _, r := range ranges {
dvRanges = append(dvRanges, map[string]interface{}{"range": r})
}
data, err := runtime.CallAPI("DELETE", dataValidationBasePath(token), nil,
map[string]interface{}{
"dataValidationRanges": dvRanges,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,552 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── SetDropdown ─────────────────────────────────────────────────────────────
func TestSetDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestSetDropdownValidateInvalidConditionValues(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": "not-json",
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestSetDropdownValidateNonStringConditionValues(t *testing.T) {
t.Parallel()
cases := []struct {
name string
input string
}{
{"mixed types", `["ok", 1, null]`},
{"all numbers", `[1, 2, 3]`},
{"null literal", `null`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": tc.input,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must be") {
t.Fatalf("expected validation error for %q, got: %v", tc.input, err)
}
})
}
}
func TestSetDropdownValidateInvalidColors(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "bad-json",
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
t.Fatalf("expected colors JSON error, got: %v", err)
}
}
func TestSetDropdownValidateRangeMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "A2:A100", "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestSetDropdownValidateEmptyConditionValues(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `[]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--condition-values must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestSetDropdownValidateColorsMismatchLength(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["a","b","c"]`,
"colors": `["#FF0000"]`,
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetSetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors length") {
t.Fatalf("expected length mismatch error, got: %v", err)
}
}
func TestSetDropdownValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
if err := SheetSetDropdown.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSetDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test",
"range": "s1!A2:A100", "condition-values": `["opt1","opt2"]`,
"colors": "",
}, map[string]bool{"multiple": true, "highlight": false})
got := mustMarshalSheetsDryRun(t, SheetSetDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"POST"`) {
t.Fatalf("DryRun should use POST: %s", got)
}
if !strings.Contains(got, `dataValidation`) {
t.Fatalf("DryRun missing dataValidation: %s", got)
}
if !strings.Contains(got, `"dataValidationType":"list"`) {
t.Fatalf("DryRun missing dataValidationType: %s", got)
}
}
func TestSetDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["opt1","opt2","opt3"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestSetDropdownExecuteWithMultipleAndColors(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["a","b"]`,
"--multiple", "--highlight", "--colors", `["#1FB6C1","#F006C2"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
dv, _ := body["dataValidation"].(map[string]interface{})
opts, _ := dv["options"].(map[string]interface{})
if opts["multipleValues"] != true {
t.Fatalf("expected multipleValues=true, got: %v", opts["multipleValues"])
}
if opts["highlightValidData"] != true {
t.Fatalf("expected highlightValidData=true, got: %v", opts["highlightValidData"])
}
}
func TestSetDropdownExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
func TestSetDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetSetDropdown, []string{
"+set-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--range", "s1!A2:A100", "--condition-values", `["opt1"]`,
"--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── UpdateDropdown ──────────────────────────────────────────────────────────
func TestUpdateDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1",
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestUpdateDropdownValidateInvalidRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": "not-json", "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestUpdateDropdownValidateRangesMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `["A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestUpdateDropdownValidateEmptyRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `[]`, "condition-values": `["opt1"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestUpdateDropdownValidateInvalidColors(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1",
"ranges": `["s1!A1:A100"]`, "condition-values": `["opt1"]`,
"colors": "{not-array}",
}, map[string]bool{"multiple": false, "highlight": true})
err := SheetUpdateDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--colors must be a JSON array") {
t.Fatalf("expected colors JSON error, got: %v", err)
}
}
func TestUpdateDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"ranges": `["sheet1!A1:A100"]`, "condition-values": `["new1","new2"]`,
"colors": "",
}, map[string]bool{"multiple": false, "highlight": false})
got := mustMarshalSheetsDryRun(t, SheetUpdateDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
if !strings.Contains(got, `sheet1`) {
t.Fatalf("DryRun missing sheet_id: %s", got)
}
}
func TestUpdateDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation/sheet1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"spreadsheetToken": "shtTOKEN", "sheetId": "sheet1",
}},
})
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
"+update-dropdown", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
"--condition-values", `["new1","new2"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation/sheet1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetUpdateDropdown, []string{
"+update-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--ranges", `["sheet1!A1:A100"]`,
"--condition-values", `["opt1"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── GetDropdown ─────────────────────────────────────────────────────────────
func TestGetDropdownValidateRangeMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "range": "A2:A100",
}, nil)
err := SheetGetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestGetDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "range": "s1!A2:A100",
}, nil)
err := SheetGetDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestGetDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "range": "s1!A2:A100",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetGetDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"GET"`) {
t.Fatalf("DryRun should use GET: %s", got)
}
if !strings.Contains(got, `dataValidation`) {
t.Fatalf("DryRun missing dataValidation path: %s", got)
}
}
func TestGetDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
"dataValidations": []interface{}{
map[string]interface{}{
"dataValidationType": "list",
"conditionValues": []interface{}{"opt1", "opt2"},
"ranges": []interface{}{"s1!A2:A100"},
},
},
}},
})
err := mountAndRunSheets(t, SheetGetDropdown, []string{
"+get-dropdown", "--spreadsheet-token", "shtTOKEN",
"--range", "s1!A2:A100", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "dataValidations") {
t.Fatalf("stdout missing dataValidations: %s", stdout.String())
}
}
func TestGetDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "Success", "data": map[string]interface{}{
"dataValidations": []interface{}{},
}},
})
err := mountAndRunSheets(t, SheetGetDropdown, []string{
"+get-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--range", "s1!A2:A100", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DeleteDropdown ──────────────────────────────────────────────────────────
func TestDeleteDropdownValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "ranges": `["s1!A2:A100"]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestDeleteDropdownValidateRangesMissingSheetID(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": `["B1:B50"]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "fully qualified range") {
t.Fatalf("expected range validation error, got: %v", err)
}
}
func TestDeleteDropdownValidateEmptyRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": `[]`,
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must not be empty") {
t.Fatalf("expected empty error, got: %v", err)
}
}
func TestDeleteDropdownValidateInvalidRanges(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "ranges": "bad",
}, nil)
err := SheetDeleteDropdown.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--ranges must be a JSON array") {
t.Fatalf("expected JSON array error, got: %v", err)
}
}
func TestDeleteDropdownDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "ranges": `["s1!A2:A100","s1!C1:C50"]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteDropdown.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
if !strings.Contains(got, `dataValidationRanges`) {
t.Fatalf("DryRun missing dataValidationRanges: %s", got)
}
}
func TestDeleteDropdownExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"rangeResults": []interface{}{
map[string]interface{}{"range": "s1!A2:A100", "success": true, "updatedCells": 99},
},
}},
})
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
"--ranges", `["s1!A2:A100"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "rangeResults") {
t.Fatalf("stdout missing rangeResults: %s", stdout.String())
}
}
func TestDeleteDropdownExecuteMultipleRanges(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtTOKEN/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--spreadsheet-token", "shtTOKEN",
"--ranges", `["s1!A2:A100","s1!C1:C50"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
dvRanges, _ := body["dataValidationRanges"].([]interface{})
if len(dvRanges) != 2 {
t.Fatalf("expected 2 ranges, got: %d", len(dvRanges))
}
}
func TestDeleteDropdownWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v2/spreadsheets/shtFromURL/dataValidation",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteDropdown, []string{
"+delete-dropdown", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--ranges", `["s1!A2:A100"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// suppress unused import for bytes in case the test helpers already import it
var _ = (*bytes.Buffer)(nil)

View File

@@ -0,0 +1,239 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func filterViewBasePath(token, sheetID string) string {
return fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/filter_views",
validate.EncodePathSegment(token), validate.EncodePathSegment(sheetID))
}
func filterViewItemPath(token, sheetID, filterViewID string) string {
return fmt.Sprintf("%s/%s", filterViewBasePath(token, sheetID), validate.EncodePathSegment(filterViewID))
}
func validateFilterViewToken(runtime *common.RuntimeContext) (string, error) {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return "", common.FlagErrorf("specify --url or --spreadsheet-token")
}
return token, nil
}
var SheetCreateFilterView = common.Shortcut{
Service: "sheets",
Command: "+create-filter-view",
Description: "Create a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "range", Desc: "filter range (e.g. sheetId!A1:H14)", Required: true},
{Name: "filter-view-name", Desc: "display name (max 100 chars)"},
{Name: "filter-view-id", Desc: "custom 10-char alphanumeric ID (auto-generated if omitted)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{"range": runtime.Str("range")}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
if s := runtime.Str("filter-view-id"); s != "" {
body["filter_view_id"] = s
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{"range": runtime.Str("range")}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
if s := runtime.Str("filter-view-id"); s != "" {
body["filter_view_id"] = s
}
data, err := runtime.CallAPI("POST", filterViewBasePath(token, runtime.Str("sheet-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetUpdateFilterView = common.Shortcut{
Service: "sheets",
Command: "+update-filter-view",
Description: "Update a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "range", Desc: "new filter range"},
{Name: "filter-view-name", Desc: "new display name (max 100 chars)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
if !runtime.Cmd.Flags().Changed("range") &&
!runtime.Cmd.Flags().Changed("filter-view-name") {
return common.FlagErrorf("specify at least one of --range or --filter-view-name")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{}
if s := runtime.Str("range"); s != "" {
body["range"] = s
}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
return common.NewDryRunAPI().
PATCH("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := map[string]interface{}{}
if s := runtime.Str("range"); s != "" {
body["range"] = s
}
if s := runtime.Str("filter-view-name"); s != "" {
body["filter_view_name"] = s
}
data, err := runtime.CallAPI("PATCH", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetListFilterViews = common.Shortcut{
Service: "sheets",
Command: "+list-filter-views",
Description: "List all filter views in a sheet",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/query").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET", filterViewBasePath(token, runtime.Str("sheet-id"))+"/query", nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetGetFilterView = common.Shortcut{
Service: "sheets",
Command: "+get-filter-view",
Description: "Get a filter view by ID",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetDeleteFilterView = common.Shortcut{
Service: "sheets",
Command: "+delete-filter-view",
Description: "Delete a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("DELETE", filterViewItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,261 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
func filterViewConditionBasePath(token, sheetID, filterViewID string) string {
return fmt.Sprintf("%s/conditions", filterViewItemPath(token, sheetID, filterViewID))
}
func filterViewConditionItemPath(token, sheetID, filterViewID, conditionID string) string {
return fmt.Sprintf("%s/%s", filterViewConditionBasePath(token, sheetID, filterViewID), validate.EncodePathSegment(conditionID))
}
var SheetCreateFilterViewCondition = common.Shortcut{
Service: "sheets",
Command: "+create-filter-view-condition",
Description: "Create a filter condition on a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color", Required: true},
{Name: "compare-type", Desc: "comparison operator (e.g. less, beginsWith, between)"},
{Name: "expected", Desc: "filter values JSON array (e.g. [\"6\"])", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
return validateExpectedFlag(runtime.Str("expected"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, true)
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, true)
data, err := runtime.CallAPI("POST", filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id")), nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetUpdateFilterViewCondition = common.Shortcut{
Service: "sheets",
Command: "+update-filter-view-condition",
Description: "Update a filter condition on a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
{Name: "filter-type", Desc: "filter type: hiddenValue, number, text, color"},
{Name: "compare-type", Desc: "comparison operator"},
{Name: "expected", Desc: "filter values JSON array"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := validateFilterViewToken(runtime); err != nil {
return err
}
if !runtime.Cmd.Flags().Changed("filter-type") &&
!runtime.Cmd.Flags().Changed("compare-type") &&
!runtime.Cmd.Flags().Changed("expected") {
return common.FlagErrorf("specify at least one of --filter-type, --compare-type, or --expected")
}
if s := runtime.Str("expected"); s != "" {
return validateExpectedFlag(s)
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, false)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
Body(body).Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
body := buildConditionBody(runtime, false)
data, err := runtime.CallAPI("PUT",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, body)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetListFilterViewConditions = common.Shortcut{
Service: "sheets",
Command: "+list-filter-view-conditions",
Description: "List all filter conditions of a filter view",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/query").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).Set("filter_view_id", runtime.Str("filter-view-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET",
filterViewConditionBasePath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"))+"/query",
nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetGetFilterViewCondition = common.Shortcut{
Service: "sheets",
Command: "+get-filter-view-condition",
Description: "Get a filter condition by column",
Risk: "read",
Scopes: []string{"sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
GET("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("GET",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
var SheetDeleteFilterViewCondition = common.Shortcut{
Service: "sheets",
Command: "+delete-filter-view-condition",
Description: "Delete a filter condition from a filter view",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL (required if --spreadsheet-token is not set)"},
{Name: "spreadsheet-token", Desc: "spreadsheet token (required if --url is not set)"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "filter-view-id", Desc: "filter view ID", Required: true},
{Name: "condition-id", Desc: "column letter (e.g. E)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := validateFilterViewToken(runtime)
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token, _ := validateFilterViewToken(runtime)
return common.NewDryRunAPI().
DELETE("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/filter_views/:filter_view_id/conditions/:condition_id").
Set("token", token).Set("sheet_id", runtime.Str("sheet-id")).
Set("filter_view_id", runtime.Str("filter-view-id")).Set("condition_id", runtime.Str("condition-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token, _ := validateFilterViewToken(runtime)
data, err := runtime.CallAPI("DELETE",
filterViewConditionItemPath(token, runtime.Str("sheet-id"), runtime.Str("filter-view-id"), runtime.Str("condition-id")),
nil, nil)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}
// validateExpectedFlag checks that --expected is a valid JSON array.
func validateExpectedFlag(s string) error {
if s == "" {
return nil
}
var arr []interface{}
if err := json.Unmarshal([]byte(s), &arr); err != nil {
return fmt.Errorf("--expected must be a JSON array (e.g. [\"6\"]), got: %s", s)
}
return nil
}
// buildConditionBody constructs the request body for condition create/update.
func buildConditionBody(runtime *common.RuntimeContext, includeConditionID bool) map[string]interface{} {
body := map[string]interface{}{}
if includeConditionID {
body["condition_id"] = runtime.Str("condition-id")
}
if s := runtime.Str("filter-type"); s != "" {
body["filter_type"] = s
}
if s := runtime.Str("compare-type"); s != "" {
body["compare_type"] = s
}
if s := runtime.Str("expected"); s != "" {
var arr []interface{}
// Validate already ensures this is a valid JSON array.
_ = json.Unmarshal([]byte(s), &arr)
body["expected"] = arr
}
return body
}

View File

@@ -0,0 +1,628 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
// ── CreateFilterView ─────────────────────────────────────────────────────────
func TestCreateFilterViewValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "range": "s1!A1:H14",
"filter-view-name": "", "filter-view-id": "",
}, nil)
err := SheetCreateFilterView.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestCreateFilterViewValidateSuccess(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht1", "sheet-id": "s1", "range": "s1!A1:H14",
"filter-view-name": "", "filter-view-id": "",
}, nil)
if err := SheetCreateFilterView.Validate(context.Background(), rt); err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateFilterViewDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "range": "sheet1!A1:H14",
"filter-view-name": "my view", "filter-view-id": "",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetCreateFilterView.DryRun(context.Background(), rt))
if !strings.Contains(got, `filter_views`) {
t.Fatalf("DryRun URL missing filter_views: %s", got)
}
if !strings.Contains(got, `"filter_view_name":"my view"`) {
t.Fatalf("DryRun missing name: %s", got)
}
}
func TestCreateFilterViewExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "pH9hbVcCXA", "range": "sheet1!A1:H14"},
}},
})
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
"+create-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "filter_view_id") {
t.Fatalf("stdout missing filter_view_id: %s", stdout.String())
}
}
func TestCreateFilterViewExecuteAPIError(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views",
Status: 400, Body: map[string]interface{}{"code": 90001, "msg": "invalid"},
})
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
"+create-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error")
}
}
// ── UpdateFilterView ─────────────────────────────────────────────────────────
func TestUpdateFilterViewDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"filter-view-id": "pH9hbVcCXA", "range": "sheet1!A1:J20", "filter-view-name": "",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetUpdateFilterView.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PATCH"`) {
t.Fatalf("DryRun should use PATCH: %s", got)
}
if !strings.Contains(got, `pH9hbVcCXA`) {
t.Fatalf("DryRun missing filter_view_id: %s", got)
}
}
func TestUpdateFilterViewExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv123", "range": "sheet1!A1:J20"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
"+update-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--range", "sheet1!A1:J20", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFilterViewRejectsNoFields(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
"+update-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected validation error when no update fields provided, got nil")
}
if !strings.Contains(err.Error(), "at least one") {
t.Fatalf("unexpected error message: %v", err)
}
}
// ── ListFilterViews ──────────────────────────────────────────────────────────
func TestListFilterViewsDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetListFilterViews.DryRun(context.Background(), rt))
if !strings.Contains(got, `filter_views/query`) {
t.Fatalf("DryRun URL missing query: %s", got)
}
}
func TestListFilterViewsExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"filter_view_id": "fv1"}},
}},
})
err := mountAndRunSheets(t, SheetListFilterViews, []string{
"+list-filter-views", "--spreadsheet-token", "shtTOKEN", "--sheet-id", "sheet1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !strings.Contains(stdout.String(), "fv1") {
t.Fatalf("stdout missing fv1: %s", stdout.String())
}
}
// ── GetFilterView ────────────────────────────────────────────────────────────
func TestGetFilterViewDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv123",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetGetFilterView.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"GET"`) {
t.Fatalf("DryRun should use GET: %s", got)
}
if !strings.Contains(got, `fv123`) {
t.Fatalf("DryRun missing filter_view_id: %s", got)
}
}
func TestGetFilterViewExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv123"},
}},
})
err := mountAndRunSheets(t, SheetGetFilterView, []string{
"+get-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DeleteFilterView ─────────────────────────────────────────────────────────
func TestDeleteFilterViewDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv123",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteFilterView.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
}
func TestDeleteFilterViewExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv123",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFilterView, []string{
"+delete-filter-view", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv123", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── CreateFilterViewCondition ────────────────────────────────────────────────
func TestCreateFilterViewConditionValidateMissingToken(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "", "sheet-id": "s1", "filter-view-id": "fv1",
"condition-id": "E", "filter-type": "number", "compare-type": "less", "expected": `["6"]`,
}, nil)
err := SheetCreateFilterViewCondition.Validate(context.Background(), rt)
if err == nil || !strings.Contains(err.Error(), "--url or --spreadsheet-token") {
t.Fatalf("expected token error, got: %v", err)
}
}
func TestCreateFilterViewConditionDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
"condition-id": "E", "filter-type": "number", "compare-type": "less", "expected": `["6"]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetCreateFilterViewCondition.DryRun(context.Background(), rt))
if !strings.Contains(got, `conditions`) {
t.Fatalf("DryRun URL missing conditions: %s", got)
}
if !strings.Contains(got, `"condition_id":"E"`) {
t.Fatalf("DryRun missing condition_id: %s", got)
}
if !strings.Contains(got, `"filter_type":"number"`) {
t.Fatalf("DryRun missing filter_type: %s", got)
}
}
func TestCreateFilterViewConditionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
stub := &httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E", "filter_type": "number"},
}},
}
reg.Register(stub)
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "E", "--filter-type", "number", "--compare-type", "less",
"--expected", `["6"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("parse body: %v", err)
}
if body["condition_id"] != "E" {
t.Fatalf("unexpected condition_id: %v", body["condition_id"])
}
}
// ── UpdateFilterViewCondition ────────────────────────────────────────────────
func TestUpdateFilterViewConditionDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
"condition-id": "E", "filter-type": "number", "compare-type": "between", "expected": `["2","10"]`,
}, nil)
got := mustMarshalSheetsDryRun(t, SheetUpdateFilterViewCondition.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"PUT"`) {
t.Fatalf("DryRun should use PUT: %s", got)
}
if !strings.Contains(got, `"compare_type":"between"`) {
t.Fatalf("DryRun missing compare_type: %s", got)
}
}
func TestUpdateFilterViewConditionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
"+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
"--filter-type", "number", "--compare-type", "between", "--expected", `["2","10"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFilterViewConditionRejectsNoFields(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
"+update-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
"--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected validation error when no update fields provided, got nil")
}
if !strings.Contains(err.Error(), "at least one") {
t.Fatalf("unexpected error message: %v", err)
}
}
// ── ListFilterViewConditions ─────────────────────────────────────────────────
func TestListFilterViewConditionsDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1", "filter-view-id": "fv1",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetListFilterViewConditions.DryRun(context.Background(), rt))
if !strings.Contains(got, `conditions/query`) {
t.Fatalf("DryRun URL missing conditions/query: %s", got)
}
}
func TestListFilterViewConditionsExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"items": []interface{}{map[string]interface{}{"condition_id": "E"}},
}},
})
err := mountAndRunSheets(t, SheetListFilterViewConditions, []string{
"+list-filter-view-conditions", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── GetFilterViewCondition ───────────────────────────────────────────────────
func TestGetFilterViewConditionDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"filter-view-id": "fv1", "condition-id": "E",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetGetFilterViewCondition.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"GET"`) {
t.Fatalf("DryRun should use GET: %s", got)
}
}
func TestGetFilterViewConditionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E", "filter_type": "number"},
}},
})
err := mountAndRunSheets(t, SheetGetFilterViewCondition, []string{
"+get-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── DeleteFilterViewCondition ────────────────────────────────────────────────
func TestDeleteFilterViewConditionDryRun(t *testing.T) {
t.Parallel()
rt := newSheetsTestRuntime(t, map[string]string{
"url": "", "spreadsheet-token": "sht_test", "sheet-id": "sheet1",
"filter-view-id": "fv1", "condition-id": "E",
}, nil)
got := mustMarshalSheetsDryRun(t, SheetDeleteFilterViewCondition.DryRun(context.Background(), rt))
if !strings.Contains(got, `"method":"DELETE"`) {
t.Fatalf("DryRun should use DELETE: %s", got)
}
}
func TestDeleteFilterViewConditionExecuteSuccess(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtTOKEN/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFilterViewCondition, []string{
"+delete-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── URL flag coverage ────────────────────────────────────────────────────────
func TestCreateFilterViewWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
}},
})
err := mountAndRunSheets(t, SheetCreateFilterView, []string{
"+create-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--range", "sheet1!A1:H14", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestListFilterViewsWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
})
err := mountAndRunSheets(t, SheetListFilterViews, []string{
"+list-filter-views", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestGetFilterViewWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
}},
})
err := mountAndRunSheets(t, SheetGetFilterView, []string{
"+get-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFilterViewWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PATCH", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"filter_view": map[string]interface{}{"filter_view_id": "fv1"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFilterView, []string{
"+update-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--range", "sheet1!A1:J20", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDeleteFilterViewWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFilterView, []string{
"+delete-filter-view", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestCreateFilterViewConditionWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "POST", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E"},
}},
})
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
"+create-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "E", "--filter-type", "number", "--compare-type", "less",
"--expected", `["6"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestUpdateFilterViewConditionWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "PUT", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E"},
}},
})
err := mountAndRunSheets(t, SheetUpdateFilterViewCondition, []string{
"+update-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E",
"--filter-type", "number", "--expected", `["5"]`, "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestListFilterViewConditionsWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/query",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{"items": []interface{}{}}},
})
err := mountAndRunSheets(t, SheetListFilterViewConditions, []string{
"+list-filter-view-conditions", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestGetFilterViewConditionWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{
"condition": map[string]interface{}{"condition_id": "E"},
}},
})
err := mountAndRunSheets(t, SheetGetFilterViewCondition, []string{
"+get-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
func TestDeleteFilterViewConditionWithURL(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, sheetsTestConfig())
reg.Register(&httpmock.Stub{
Method: "DELETE", URL: "/open-apis/sheets/v3/spreadsheets/shtFromURL/sheets/sheet1/filter_views/fv1/conditions/E",
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
})
err := mountAndRunSheets(t, SheetDeleteFilterViewCondition, []string{
"+delete-filter-view-condition", "--url", "https://example.feishu.cn/sheets/shtFromURL",
"--sheet-id", "sheet1", "--filter-view-id", "fv1", "--condition-id", "E", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
// ── --expected validation rejects non-array input ────────────────────────────
func TestCreateFilterViewConditionRejectsNonArrayExpected(t *testing.T) {
cases := []struct {
name string
expected string
}{
{"plain string", "hello"},
{"JSON object", `{"key":"val"}`},
{"JSON number", "42"},
{"JSON string", `"hello"`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, sheetsTestConfig())
err := mountAndRunSheets(t, SheetCreateFilterViewCondition, []string{
"+create-filter-view-condition", "--spreadsheet-token", "shtTOKEN",
"--sheet-id", "sheet1", "--filter-view-id", "fv1",
"--condition-id", "A", "--filter-type", "text", "--compare-type", "contains",
"--expected", tc.expected, "--as", "user",
}, f, stdout)
if err == nil {
t.Fatalf("expected validation error for --expected=%q, got nil", tc.expected)
}
if !strings.Contains(err.Error(), "--expected must be a JSON array") {
t.Fatalf("unexpected error message: %v", err)
}
})
}
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetInsertDimension = common.Shortcut{
Service: "sheets",
Command: "+insert-dimension",
Description: "Insert rows or columns at a specified position",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "start position (0-indexed)", Required: true},
{Name: "end-index", Type: "int", Desc: "end position (0-indexed, exclusive)", Required: true},
{Name: "inherit-style", Desc: "style inheritance: BEFORE or AFTER", Enum: []string{"BEFORE", "AFTER"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if runtime.Int("start-index") < 0 {
return common.FlagErrorf("--start-index must be >= 0")
}
if runtime.Int("end-index") <= runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be greater than --start-index")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
body := map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
}
if s := runtime.Str("inherit-style"); s != "" {
body["inheritStyle"] = s
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/insert_dimension_range").
Body(body).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
body := map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
}
if s := runtime.Str("inherit-style"); s != "" {
body["inheritStyle"] = s
}
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/insert_dimension_range", validate.EncodePathSegment(token)),
nil, body,
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,77 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetMergeCells = common.Shortcut{
Service: "sheets",
Command: "+merge-cells",
Description: "Merge cells in a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
{Name: "merge-type", Desc: "merge method", Required: true, Enum: []string{"MERGE_ALL", "MERGE_ROWS", "MERGE_COLUMNS"}},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/merge_cells").
Body(map[string]interface{}{
"range": r,
"mergeType": runtime.Str("merge-type"),
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/merge_cells", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"range": r,
"mergeType": runtime.Str("merge-type"),
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetMoveDimension = common.Shortcut{
Service: "sheets",
Command: "+move-dimension",
Description: "Move rows or columns to a new position",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "source start position (0-indexed)", Required: true},
{Name: "end-index", Type: "int", Desc: "source end position (0-indexed, inclusive)", Required: true},
{Name: "destination-index", Type: "int", Desc: "target position to move to (0-indexed)", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if runtime.Int("start-index") < 0 {
return common.FlagErrorf("--start-index must be >= 0")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
}
if runtime.Int("destination-index") < 0 {
return common.FlagErrorf("--destination-index must be >= 0")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/move_dimension").
Body(map[string]interface{}{
"source": map[string]interface{}{
"major_dimension": runtime.Str("dimension"),
"start_index": runtime.Int("start-index"),
"end_index": runtime.Int("end-index"),
},
"destination_index": runtime.Int("destination-index"),
}).
Set("token", token).
Set("sheet_id", runtime.Str("sheet-id"))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/move_dimension",
validate.EncodePathSegment(token),
validate.EncodePathSegment(runtime.Str("sheet-id")),
),
nil,
map[string]interface{}{
"source": map[string]interface{}{
"major_dimension": runtime.Str("dimension"),
"start_index": runtime.Int("start-index"),
"end_index": runtime.Int("end-index"),
},
"destination_index": runtime.Int("destination-index"),
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,112 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetReplace = common.Shortcut{
Service: "sheets",
Command: "+replace",
Description: "Find and replace cell values in a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "sheet ID", Required: true},
{Name: "find", Desc: "search text or regex pattern", Required: true},
{Name: "replacement", Desc: "replacement text", Required: true},
{Name: "range", Desc: "search range (<sheetId>!A1:D10, or A1:D10 with --sheet-id)"},
{Name: "match-case", Type: "bool", Desc: "case-sensitive search"},
{Name: "match-entire-cell", Type: "bool", Desc: "match entire cell content"},
{Name: "search-by-regex", Type: "bool", Desc: "use regex search"},
{Name: "include-formulas", Type: "bool", Desc: "search in formulas"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
if r := runtime.Str("range"); r != "" {
if rangeSheetID, _, ok := splitSheetRange(r); ok && runtime.Str("sheet-id") != "" && rangeSheetID != runtime.Str("sheet-id") {
return common.FlagErrorf("--range sheet ID %q does not match --sheet-id %q", rangeSheetID, runtime.Str("sheet-id"))
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
sheetID := runtime.Str("sheet-id")
findCondition := map[string]interface{}{
"range": sheetID,
"match_case": runtime.Bool("match-case"),
"match_entire_cell": runtime.Bool("match-entire-cell"),
"search_by_regex": runtime.Bool("search-by-regex"),
"include_formulas": runtime.Bool("include-formulas"),
}
if runtime.Str("range") != "" {
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
}
return common.NewDryRunAPI().
POST("/open-apis/sheets/v3/spreadsheets/:token/sheets/:sheet_id/replace").
Body(map[string]interface{}{
"find_condition": findCondition,
"find": runtime.Str("find"),
"replacement": runtime.Str("replacement"),
}).
Set("token", token).Set("sheet_id", sheetID)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
sheetID := runtime.Str("sheet-id")
findCondition := map[string]interface{}{
"range": sheetID,
"match_case": runtime.Bool("match-case"),
"match_entire_cell": runtime.Bool("match-entire-cell"),
"search_by_regex": runtime.Bool("search-by-regex"),
"include_formulas": runtime.Bool("include-formulas"),
}
if runtime.Str("range") != "" {
findCondition["range"] = normalizeSheetRange(sheetID, runtime.Str("range"))
}
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v3/spreadsheets/%s/sheets/%s/replace",
validate.EncodePathSegment(token),
validate.EncodePathSegment(sheetID),
),
nil,
map[string]interface{}{
"find_condition": findCondition,
"find": runtime.Str("find"),
"replacement": runtime.Str("replacement"),
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,95 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"encoding/json"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetSetStyle = common.Shortcut{
Service: "sheets",
Command: "+set-style",
Description: "Set cell style for a range",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
{Name: "style", Desc: "style JSON object (e.g. {\"font\":{\"bold\":true},\"backColor\":\"#ff0000\"})", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
var style interface{}
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
return common.FlagErrorf("--style must be valid JSON: %v", err)
}
if _, ok := style.(map[string]interface{}); !ok {
return common.FlagErrorf("--style must be a JSON object, got %T", style)
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
json.Unmarshal([]byte(runtime.Str("style")), &style)
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/style").
Body(map[string]interface{}{
"appendStyle": map[string]interface{}{
"range": r,
"style": style,
},
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
var style interface{}
if err := json.Unmarshal([]byte(runtime.Str("style")), &style); err != nil {
return common.FlagErrorf("--style must be valid JSON: %v", err)
}
data, err := runtime.CallAPI("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/style", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"appendStyle": map[string]interface{}{
"range": r,
"style": style,
},
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,74 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetUnmergeCells = common.Shortcut{
Service: "sheets",
Command: "+unmerge-cells",
Description: "Unmerge (split) cells in a spreadsheet",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "range", Desc: "cell range (<sheetId>!A1:B2, or A1:B2 with --sheet-id)", Required: true},
{Name: "sheet-id", Desc: "sheet ID (for relative range)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if err := validateSheetRangeInput(runtime.Str("sheet-id"), runtime.Str("range")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
return common.NewDryRunAPI().
POST("/open-apis/sheets/v2/spreadsheets/:token/unmerge_cells").
Body(map[string]interface{}{
"range": r,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
r := normalizeSheetRange(runtime.Str("sheet-id"), runtime.Str("range"))
data, err := runtime.CallAPI("POST",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/unmerge_cells", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"range": r,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -0,0 +1,111 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"context"
"fmt"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var SheetUpdateDimension = common.Shortcut{
Service: "sheets",
Command: "+update-dimension",
Description: "Update row or column properties (visibility, size)",
Risk: "write",
Scopes: []string{"sheets:spreadsheet:write_only", "sheets:spreadsheet:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "url", Desc: "spreadsheet URL"},
{Name: "spreadsheet-token", Desc: "spreadsheet token"},
{Name: "sheet-id", Desc: "worksheet ID", Required: true},
{Name: "dimension", Desc: "ROWS or COLUMNS", Required: true, Enum: []string{"ROWS", "COLUMNS"}},
{Name: "start-index", Type: "int", Desc: "start position (1-indexed, inclusive)", Required: true},
{Name: "end-index", Type: "int", Desc: "end position (1-indexed, inclusive)", Required: true},
{Name: "visible", Type: "bool", Desc: "true to show, false to hide"},
{Name: "fixed-size", Type: "int", Desc: "row height or column width in pixels"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
if token == "" {
return common.FlagErrorf("specify --url or --spreadsheet-token")
}
if runtime.Int("start-index") < 1 {
return common.FlagErrorf("--start-index must be >= 1")
}
if runtime.Int("end-index") < runtime.Int("start-index") {
return common.FlagErrorf("--end-index must be >= --start-index")
}
if !runtime.Cmd.Flags().Changed("visible") && !runtime.Cmd.Flags().Changed("fixed-size") {
return common.FlagErrorf("specify at least one of --visible or --fixed-size")
}
if runtime.Cmd.Flags().Changed("fixed-size") && runtime.Int("fixed-size") < 1 {
return common.FlagErrorf("--fixed-size must be >= 1")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
props := map[string]interface{}{}
if runtime.Cmd.Flags().Changed("visible") {
props["visible"] = runtime.Bool("visible")
}
if runtime.Cmd.Flags().Changed("fixed-size") {
props["fixedSize"] = runtime.Int("fixed-size")
}
return common.NewDryRunAPI().
PUT("/open-apis/sheets/v2/spreadsheets/:token/dimension_range").
Body(map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
"dimensionProperties": props,
}).
Set("token", token)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
token := runtime.Str("spreadsheet-token")
if runtime.Str("url") != "" {
token = extractSpreadsheetToken(runtime.Str("url"))
}
props := map[string]interface{}{}
if runtime.Cmd.Flags().Changed("visible") {
props["visible"] = runtime.Bool("visible")
}
if runtime.Cmd.Flags().Changed("fixed-size") {
props["fixedSize"] = runtime.Int("fixed-size")
}
data, err := runtime.CallAPI("PUT",
fmt.Sprintf("/open-apis/sheets/v2/spreadsheets/%s/dimension_range", validate.EncodePathSegment(token)),
nil,
map[string]interface{}{
"dimension": map[string]interface{}{
"sheetId": runtime.Str("sheet-id"),
"majorDimension": runtime.Str("dimension"),
"startIndex": runtime.Int("start-index"),
"endIndex": runtime.Int("end-index"),
},
"dimensionProperties": props,
},
)
if err != nil {
return err
}
runtime.Out(data, nil)
return nil
},
}

View File

@@ -16,5 +16,29 @@ func Shortcuts() []common.Shortcut {
SheetFind,
SheetCreate,
SheetExport,
SheetMergeCells,
SheetUnmergeCells,
SheetReplace,
SheetSetStyle,
SheetBatchSetStyle,
SheetAddDimension,
SheetInsertDimension,
SheetUpdateDimension,
SheetMoveDimension,
SheetDeleteDimension,
SheetCreateFilterView,
SheetUpdateFilterView,
SheetListFilterViews,
SheetGetFilterView,
SheetDeleteFilterView,
SheetCreateFilterViewCondition,
SheetUpdateFilterViewCondition,
SheetListFilterViewConditions,
SheetGetFilterViewCondition,
SheetDeleteFilterViewCondition,
SheetSetDropdown,
SheetUpdateDropdown,
SheetGetDropdown,
SheetDeleteDropdown,
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all slides shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlidesCreate,
}
}

View File

@@ -0,0 +1,216 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
defaultPresentationWidth = 960
defaultPresentationHeight = 540
maxSlidesPerCreate = 10
)
// SlidesCreate creates a new Lark Slides presentation with bot auto-grant.
var SlidesCreate = common.Shortcut{
Service: "slides",
Command: "+create",
Description: "Create a Lark Slides presentation",
Risk: "write",
AuthTypes: []string{"user", "bot"},
Scopes: []string{"slides:presentation:create", "slides:presentation:write_only"},
Flags: []common.Flag{
{Name: "title", Desc: "presentation title"},
{Name: "slides", Desc: "slide content JSON array (each element is a <slide> XML string, max 10; for more pages, create first then add via xml_presentation.slide.create)"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if slidesStr := runtime.Str("slides"); slidesStr != "" {
var slides []string
if err := json.Unmarshal([]byte(slidesStr), &slides); err != nil {
return common.FlagErrorf("--slides invalid JSON, must be an array of XML strings")
}
if len(slides) > maxSlidesPerCreate {
return common.FlagErrorf("--slides array exceeds maximum of %d slides; create the presentation first, then add slides via xml_presentation.slide.create", maxSlidesPerCreate)
}
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
title := effectiveTitle(runtime.Str("title"))
slidesStr := runtime.Str("slides")
createBody := map[string]interface{}{
"xml_presentation": map[string]interface{}{"content": buildPresentationXML(title)},
}
dry := common.NewDryRunAPI()
if slidesStr == "" {
dry.Desc("Create empty presentation").
POST("/open-apis/slides_ai/v1/xml_presentations").
Body(createBody)
} else {
var slides []string
_ = json.Unmarshal([]byte(slidesStr), &slides)
n := len(slides)
total := n + 1
dry.Desc(fmt.Sprintf("Create presentation + add %d slide(s)", n)).
POST("/open-apis/slides_ai/v1/xml_presentations").
Desc(fmt.Sprintf("[1/%d] Create presentation", total)).
Body(createBody)
for i, slideXML := range slides {
dry.POST("/open-apis/slides_ai/v1/xml_presentations/<xml_presentation_id>/slide").
Desc(fmt.Sprintf("[%d/%d] Add slide %d", i+2, total, i+1)).
Body(map[string]interface{}{
"slide": map[string]interface{}{"content": slideXML},
})
}
}
if runtime.IsBot() {
dry.Desc("After creation succeeds in bot mode, the CLI will also try to grant the current CLI user full_access (可管理权限) on the new presentation.")
}
return dry
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
title := effectiveTitle(runtime.Str("title"))
content := buildPresentationXML(title)
slidesStr := runtime.Str("slides")
// Step 1: Create presentation
data, err := runtime.CallAPI(
"POST",
"/open-apis/slides_ai/v1/xml_presentations",
nil,
map[string]interface{}{
"xml_presentation": map[string]interface{}{
"content": content,
},
},
)
if err != nil {
return err
}
presentationID := common.GetString(data, "xml_presentation_id")
if presentationID == "" {
return output.Errorf(output.ExitAPI, "api_error", "slides create returned no xml_presentation_id")
}
result := map[string]interface{}{
"xml_presentation_id": presentationID,
"title": title,
}
if revisionID := common.GetFloat(data, "revision_id"); revisionID > 0 {
result["revision_id"] = int(revisionID)
}
// Step 2: Add slides if provided
if slidesStr != "" {
var slides []string
_ = json.Unmarshal([]byte(slidesStr), &slides) // already validated
if len(slides) > 0 {
slideURL := fmt.Sprintf(
"/open-apis/slides_ai/v1/xml_presentations/%s/slide",
validate.EncodePathSegment(presentationID),
)
var slideIDs []string
for i, slideXML := range slides {
slideData, err := runtime.CallAPI(
"POST",
slideURL,
map[string]interface{}{"revision_id": -1},
map[string]interface{}{
"slide": map[string]interface{}{"content": slideXML},
},
)
if err != nil {
return output.Errorf(output.ExitAPI, "api_error",
"slide %d/%d failed: %v (presentation %s was created; %d slide(s) added before failure)",
i+1, len(slides), err, presentationID, i)
}
if sid := common.GetString(slideData, "slide_id"); sid != "" {
slideIDs = append(slideIDs, sid)
}
}
result["slide_ids"] = slideIDs
result["slides_added"] = len(slideIDs)
}
}
// Fetch presentation URL via drive meta (best-effort)
if metaData, err := runtime.CallAPI(
"POST",
"/open-apis/drive/v1/metas/batch_query",
nil,
map[string]interface{}{
"request_docs": []map[string]interface{}{
{
"doc_token": presentationID,
"doc_type": "slides",
},
},
"with_url": true,
},
); err == nil {
metas := common.GetSlice(metaData, "metas")
if len(metas) > 0 {
if meta, ok := metas[0].(map[string]interface{}); ok {
if url := common.GetString(meta, "url"); url != "" {
result["url"] = url
}
}
}
}
if grant := common.AutoGrantCurrentUserDrivePermission(runtime, presentationID, "slides"); grant != nil {
result["permission_grant"] = grant
}
runtime.Out(result, nil)
return nil
},
}
// effectiveTitle returns the title to use, falling back to "Untitled".
func effectiveTitle(title string) string {
if title == "" {
return "Untitled"
}
return title
}
// buildPresentationXML builds the minimal XML for a new empty presentation.
func buildPresentationXML(title string) string {
escapedTitle := xmlEscape(title)
if escapedTitle == "" {
escapedTitle = "Untitled"
}
return fmt.Sprintf(
`<presentation xmlns="http://www.larkoffice.com/sml/2.0" width="%d" height="%d"><title>%s</title></presentation>`,
defaultPresentationWidth, defaultPresentationHeight, escapedTitle,
)
}
// xmlEscape escapes special XML characters in text content.
func xmlEscape(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&apos;")
return s
}

View File

@@ -0,0 +1,653 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package slides
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
// TestSlidesCreateBasic verifies that slides +create returns the presentation ID, title, and URL in user mode.
func TestSlidesCreateBasic(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_abc123",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_abc123", "https://example.feishu.cn/slides/pres_abc123")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "项目汇报",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_abc123" {
t.Fatalf("xml_presentation_id = %v, want pres_abc123", data["xml_presentation_id"])
}
if data["title"] != "项目汇报" {
t.Fatalf("title = %v, want 项目汇报", data["title"])
}
if data["url"] != "https://example.feishu.cn/slides/pres_abc123" {
t.Fatalf("url = %v, want https://example.feishu.cn/slides/pres_abc123", data["url"])
}
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode")
}
}
// TestSlidesCreateBotAutoGrant verifies that bot mode grants the current user full_access on the new presentation.
func TestSlidesCreateBotAutoGrant(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, "ou_current_user"))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_bot",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_bot", "https://example.feishu.cn/slides/pres_bot")
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/permissions/pres_bot/members",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"member": map[string]interface{}{
"member_id": "ou_current_user",
"member_type": "openid",
"perm": "full_access",
},
},
},
})
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Bot PPT",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantGranted {
t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantGranted)
}
if !strings.Contains(grant["message"].(string), "presentation") {
t.Fatalf("permission_grant.message = %q, want 'presentation' mention", grant["message"])
}
}
// TestSlidesCreateBotSkippedWithoutCurrentUser verifies that permission grant is skipped when no user open_id is configured.
func TestSlidesCreateBotSkippedWithoutCurrentUser(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_no_user",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_no_user", "https://example.feishu.cn/slides/pres_no_user")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "No User PPT",
"--as", "bot",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
grant, _ := data["permission_grant"].(map[string]interface{})
if grant["status"] != common.PermissionGrantSkipped {
t.Fatalf("permission_grant.status = %v, want %q", grant["status"], common.PermissionGrantSkipped)
}
}
// TestSlidesCreateDryRunDefaultTitle verifies that dry-run also normalizes an empty title to "Untitled".
func TestSlidesCreateDryRunDefaultTitle(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "Untitled") {
t.Fatalf("dry-run should contain Untitled in XML payload, got: %s", out)
}
if !strings.Contains(out, "xml_presentations") {
t.Fatalf("dry-run should show API path, got: %s", out)
}
}
// TestSlidesCreateDefaultTitle verifies that omitting --title outputs "Untitled" (matching the actual resource).
func TestSlidesCreateDefaultTitle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_default",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_default", "https://example.feishu.cn/slides/pres_default")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["title"] != "Untitled" {
t.Fatalf("title = %v, want Untitled", data["title"])
}
}
// TestSlidesCreateMissingPresentationID verifies the error when the API returns no xml_presentation_id.
func TestSlidesCreateMissingPresentationID(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"revision_id": 1,
},
},
})
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Missing ID",
"--as", "user",
})
if err == nil {
t.Fatal("expected error when xml_presentation_id is missing, got nil")
}
if !strings.Contains(err.Error(), "xml_presentation_id") {
t.Fatalf("error = %q, want mention of xml_presentation_id", err.Error())
}
}
// TestSlidesCreateWithSlides verifies that slides +create with --slides creates the presentation and adds slides.
func TestSlidesCreateWithSlides(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_with_slides",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_with_slides", "https://example.feishu.cn/slides/pres_with_slides")
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_with_slides/slide",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"slide_id": "slide_001",
"revision_id": 2,
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_with_slides/slide",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"slide_id": "slide_002",
"revision_id": 3,
},
},
})
slidesJSON := `["<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>","<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"]`
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "With Slides",
"--slides", slidesJSON,
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_with_slides" {
t.Fatalf("xml_presentation_id = %v, want pres_with_slides", data["xml_presentation_id"])
}
slideIDs, ok := data["slide_ids"].([]interface{})
if !ok || len(slideIDs) != 2 {
t.Fatalf("slide_ids = %v, want 2 elements", data["slide_ids"])
}
if slideIDs[0] != "slide_001" || slideIDs[1] != "slide_002" {
t.Fatalf("slide_ids = %v, want [slide_001, slide_002]", slideIDs)
}
if data["slides_added"] != float64(2) {
t.Fatalf("slides_added = %v, want 2", data["slides_added"])
}
}
// TestSlidesCreateWithSlidesPartialFailure verifies error reporting when a slide fails to create.
func TestSlidesCreateWithSlidesPartialFailure(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_partial",
"revision_id": 1,
},
},
})
// First slide succeeds
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_partial/slide",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"slide_id": "slide_ok",
"revision_id": 2,
},
},
})
// Second slide fails
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations/pres_partial/slide",
Body: map[string]interface{}{
"code": 400,
"msg": "invalid xml",
},
})
slidesJSON := `["<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>","<bad-xml>"]`
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Partial",
"--slides", slidesJSON,
"--as", "user",
})
if err == nil {
t.Fatal("expected error for partial failure, got nil")
}
errMsg := err.Error()
if !strings.Contains(errMsg, "pres_partial") {
t.Fatalf("error should contain presentation ID, got: %s", errMsg)
}
if !strings.Contains(errMsg, "slide 2/2") {
t.Fatalf("error should indicate slide 2/2 failed, got: %s", errMsg)
}
if !strings.Contains(errMsg, "1 slide(s) added") {
t.Fatalf("error should report 1 slide added before failure, got: %s", errMsg)
}
}
// TestSlidesCreateWithSlidesInvalidJSON verifies validation rejects non-JSON slides input.
func TestSlidesCreateWithSlidesInvalidJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Bad JSON",
"--slides", "not json",
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for invalid JSON, got nil")
}
if !strings.Contains(err.Error(), "--slides invalid JSON") {
t.Fatalf("error = %q, want --slides invalid JSON mention", err.Error())
}
}
// TestSlidesCreateWithSlidesExceedsMax verifies validation rejects arrays exceeding the limit.
func TestSlidesCreateWithSlidesExceedsMax(t *testing.T) {
t.Parallel()
// Build a JSON array with 11 elements (exceeds maxSlidesPerCreate = 10)
elems := make([]string, 11)
for i := range elems {
elems[i] = `"<slide/>"` //nolint:goconst
}
slidesJSON := "[" + strings.Join(elems, ",") + "]"
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Too Many",
"--slides", slidesJSON,
"--as", "user",
})
if err == nil {
t.Fatal("expected validation error for exceeding max, got nil")
}
if !strings.Contains(err.Error(), "exceeds maximum") {
t.Fatalf("error = %q, want 'exceeds maximum' mention", err.Error())
}
}
// TestSlidesCreateWithSlidesEmptyArray verifies that --slides '[]' behaves like no --slides.
func TestSlidesCreateWithSlidesEmptyArray(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_empty_slides",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_empty_slides", "https://example.feishu.cn/slides/pres_empty_slides")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "Empty Slides",
"--slides", "[]",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_empty_slides" {
t.Fatalf("xml_presentation_id = %v, want pres_empty_slides", data["xml_presentation_id"])
}
if _, ok := data["slide_ids"]; ok {
t.Fatalf("did not expect slide_ids for empty slides array")
}
if _, ok := data["slides_added"]; ok {
t.Fatalf("did not expect slides_added for empty slides array")
}
}
// TestSlidesCreateWithSlidesDryRun verifies dry-run output shows multi-step labels.
func TestSlidesCreateWithSlidesDryRun(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
slidesJSON := `["<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>","<slide xmlns=\"http://www.larkoffice.com/sml/2.0\"><data></data></slide>"]`
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "DryRun Slides",
"--slides", slidesJSON,
"--dry-run",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "[1/3]") {
t.Fatalf("dry-run should contain [1/3] step label, got: %s", out)
}
if !strings.Contains(out, "[2/3]") {
t.Fatalf("dry-run should contain [2/3] step label, got: %s", out)
}
if !strings.Contains(out, "[3/3]") {
t.Fatalf("dry-run should contain [3/3] step label, got: %s", out)
}
if !strings.Contains(out, "xml_presentation_id") {
t.Fatalf("dry-run should contain placeholder xml_presentation_id, got: %s", out)
}
}
// TestSlidesCreateWithoutSlidesUnchanged verifies existing behavior when --slides is not passed.
func TestSlidesCreateWithoutSlidesUnchanged(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_no_slides",
"revision_id": 1,
},
},
})
registerBatchQueryStub(reg, "pres_no_slides", "https://example.feishu.cn/slides/pres_no_slides")
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "No Slides",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_no_slides" {
t.Fatalf("xml_presentation_id = %v, want pres_no_slides", data["xml_presentation_id"])
}
if data["title"] != "No Slides" {
t.Fatalf("title = %v, want No Slides", data["title"])
}
if _, ok := data["slide_ids"]; ok {
t.Fatalf("did not expect slide_ids when --slides not passed")
}
if _, ok := data["slides_added"]; ok {
t.Fatalf("did not expect slides_added when --slides not passed")
}
if _, ok := data["permission_grant"]; ok {
t.Fatalf("did not expect permission_grant in user mode")
}
}
// TestSlidesCreateURLFetchBestEffort verifies that the shortcut succeeds even when batch_query fails.
func TestSlidesCreateURLFetchBestEffort(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, slidesTestConfig(t, ""))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/slides_ai/v1/xml_presentations",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"xml_presentation_id": "pres_no_url",
"revision_id": 1,
},
},
})
// batch_query returns an error — URL fetch should be silently skipped
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 99999,
"msg": "no permission",
},
})
err := runSlidesCreateShortcut(t, f, stdout, []string{
"+create",
"--title", "No URL",
"--as", "user",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeSlidesCreateEnvelope(t, stdout)
if data["xml_presentation_id"] != "pres_no_url" {
t.Fatalf("xml_presentation_id = %v, want pres_no_url", data["xml_presentation_id"])
}
if _, ok := data["url"]; ok {
t.Fatalf("did not expect url when batch_query fails")
}
}
// TestXmlEscape verifies that XML special characters are properly escaped.
func TestXmlEscape(t *testing.T) {
t.Parallel()
tests := []struct {
input, want string
}{
{"hello", "hello"},
{"a&b", "a&amp;b"},
{"<script>", "&lt;script&gt;"},
{`"quoted"`, "&quot;quoted&quot;"},
{"it's", "it&apos;s"},
}
for _, tt := range tests {
got := xmlEscape(tt.input)
if got != tt.want {
t.Errorf("xmlEscape(%q) = %q, want %q", tt.input, got, tt.want)
}
}
}
// slidesTestConfig returns a CliConfig for testing with the given user open ID.
func slidesTestConfig(t *testing.T, userOpenID string) *core.CliConfig {
t.Helper()
replacer := strings.NewReplacer("/", "-", " ", "-")
suffix := replacer.Replace(strings.ToLower(t.Name()))
return &core.CliConfig{
AppID: "test-slides-create-" + suffix,
AppSecret: "secret-slides-create-" + suffix,
Brand: core.BrandFeishu,
UserOpenId: userOpenID,
}
}
// runSlidesCreateShortcut mounts and executes the slides +create shortcut with the given args.
func runSlidesCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.Buffer, args []string) error {
t.Helper()
parent := &cobra.Command{Use: "slides"}
SlidesCreate.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
// registerBatchQueryStub registers a drive meta batch_query mock that returns the given URL.
func registerBatchQueryStub(reg *httpmock.Registry, token, url string) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/metas/batch_query",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"metas": []map[string]interface{}{
{"doc_token": token, "doc_type": "slides", "title": "", "url": url},
},
},
},
})
}
// decodeSlidesCreateEnvelope parses the JSON output and returns the data map.
func decodeSlidesCreateEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("failed to decode output: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in output envelope: %#v", envelope)
}
return data
}

View File

@@ -223,6 +223,7 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{
CreateTask,
UpdateTask,
SetAncestorTask,
CommentTask,
CompleteTask,
ReopenTask,
@@ -230,7 +231,11 @@ func Shortcuts() []common.Shortcut {
FollowersTask,
ReminderTask,
GetMyTasks,
GetRelatedTasks,
SearchTask,
SubscribeTaskEvent,
CreateTasklist,
SearchTasklist,
AddTaskToTasklist,
MembersTasklist,
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
relatedTasksDefaultPageLimit = 20
relatedTasksMaxPageLimit = 40
relatedTasksPageSize = 100
)
var GetRelatedTasks = common.Shortcut{
Service: "task",
Command: "+get-related-tasks",
Description: "list tasks related to me",
Risk: "read",
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "include-complete", Type: "bool", Desc: "default true; set false to return only incomplete tasks"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
{Name: "page-token", Desc: "page token / updated_at cursor in microseconds"},
{Name: "created-by-me", Type: "bool", Desc: "client-side filter to tasks created by me; pagination still follows upstream related-task pages"},
{Name: "followed-by-me", Type: "bool", Desc: "client-side filter to tasks followed by me; pagination still follows upstream related-task pages"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
params := map[string]interface{}{
"user_id_type": "open_id",
"page_size": relatedTasksPageSize,
}
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
params["completed"] = false
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
params["page_token"] = pageToken
}
return common.NewDryRunAPI().
GET("/open-apis/task/v2/task_v2/list_related_task").
Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
queryParams.Set("page_size", fmt.Sprintf("%d", relatedTasksPageSize))
if runtime.Cmd.Flags().Changed("include-complete") && !runtime.Bool("include-complete") {
queryParams.Set("completed", "false")
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
queryParams.Set("page_token", pageToken)
}
pageLimit := runtime.Int("page-limit")
if pageLimit <= 0 {
pageLimit = relatedTasksDefaultPageLimit
}
if runtime.Bool("page-all") {
pageLimit = relatedTasksMaxPageLimit
}
if pageLimit > relatedTasksMaxPageLimit {
pageLimit = relatedTasksMaxPageLimit
}
var allItems []interface{}
var lastPageToken string
var lastHasMore bool
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/task_v2/list_related_task",
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse related tasks")
}
}
data, err := HandleTaskApiResult(result, err, "list related tasks")
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
allItems = append(allItems, items...)
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !lastHasMore || lastPageToken == "" {
break
}
queryParams.Set("page_token", lastPageToken)
}
userOpenID := runtime.UserOpenId()
filtered := make([]map[string]interface{}, 0, len(allItems))
for _, item := range allItems {
task, ok := item.(map[string]interface{})
if !ok {
continue
}
if runtime.Bool("created-by-me") {
creator, _ := task["creator"].(map[string]interface{})
if creatorID, _ := creator["id"].(string); creatorID != userOpenID {
continue
}
}
if runtime.Bool("followed-by-me") && !taskFollowedBy(task, userOpenID) {
continue
}
filtered = append(filtered, outputRelatedTask(task))
}
outData := map[string]interface{}{
"items": filtered,
"page_token": lastPageToken,
"has_more": lastHasMore,
}
runtime.OutFormat(outData, &output.Meta{Count: len(filtered)}, func(w io.Writer) {
if len(filtered) == 0 {
fmt.Fprintln(w, "No related tasks found.")
return
}
io.WriteString(w, renderRelatedTasksPretty(filtered, lastHasMore, lastPageToken))
})
return nil
},
}
func taskFollowedBy(task map[string]interface{}, userOpenID string) bool {
members, _ := task["members"].([]interface{})
for _, member := range members {
memberObj, _ := member.(map[string]interface{})
role, _ := memberObj["role"].(string)
id, _ := memberObj["id"].(string)
if strings.EqualFold(role, "follower") && id == userOpenID {
return true
}
}
return false
}

View File

@@ -0,0 +1,207 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestTaskFollowedBy(t *testing.T) {
tests := []struct {
name string
task map[string]interface{}
userOpenID string
want bool
}{
{
name: "contains follower",
task: map[string]interface{}{
"members": []interface{}{
map[string]interface{}{"id": "ou_1", "role": "assignee"},
map[string]interface{}{"id": "ou_2", "role": "follower"},
},
},
userOpenID: "ou_2",
want: true,
},
{
name: "missing follower",
task: map[string]interface{}{
"members": []interface{}{
map[string]interface{}{"id": "ou_1", "role": "assignee"},
},
},
userOpenID: "ou_3",
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := taskFollowedBy(tt.task, tt.userOpenID)
if got != tt.want {
t.Fatalf("taskFollowedBy() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetRelatedTasks_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "with page token and incomplete filter",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("include-complete", "false")
_ = cmd.Flags().Set("page-token", "pt_001")
},
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_token=pt_001", "completed=false"},
},
{
name: "default query params",
setup: func(cmd *cobra.Command) {},
wantParts: []string{"GET /open-apis/task/v2/task_v2/list_related_task", "page_size=100", "user_id_type=open_id"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().Bool("include-complete", true, "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
out := GetRelatedTasks.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
func TestGetRelatedTasks_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantParts []string
}{
{
name: "json created by me",
args: []string{"+get-related-tasks", "--as", "bot", "--format", "json", "--created-by-me"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/task_v2/list_related_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{
"guid": "task-123",
"summary": "Related Task",
"description": "desc",
"status": "done",
"source": 1,
"mode": 2,
"subtask_count": 0,
"tasklists": []interface{}{},
"url": "https://example.com/task-123",
"creator": map[string]interface{}{"id": "ou_testuser", "type": "user"},
},
},
},
},
})
},
wantParts: []string{`"guid": "task-123"`, `"summary": "Related Task"`},
},
{
name: "pretty pagination followed by me",
args: []string{"+get-related-tasks", "--as", "bot", "--format", "pretty", "--followed-by-me", "--page-limit", "2"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/task_v2/list_related_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "pt_2",
"items": []interface{}{
map[string]interface{}{
"guid": "task-1",
"summary": "Task One",
"url": "https://example.com/task-1",
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "page_token=pt_2",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{
"guid": "task-2",
"summary": "Task Two",
"url": "https://example.com/task-2",
"creator": map[string]interface{}{"id": "ou_other", "type": "user"},
"members": []interface{}{map[string]interface{}{"id": "ou_testuser", "role": "follower"}},
},
},
},
},
})
},
wantParts: []string{"Task One", "Task Two"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
s := GetRelatedTasks
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}

View File

@@ -0,0 +1,247 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"fmt"
"strconv"
"strings"
"time"
)
func splitAndTrimCSV(input string) []string {
parts := strings.Split(input, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func parseTimeRangeMillis(input string) (string, string, error) {
if strings.TrimSpace(input) == "" {
return "", "", nil
}
parts := strings.SplitN(input, ",", 2)
startInput := strings.TrimSpace(parts[0])
endInput := ""
if len(parts) == 2 {
endInput = strings.TrimSpace(parts[1])
}
var startMillis, endMillis string
var startSecInt, endSecInt int64
var hasStart, hasEnd bool
if startInput != "" {
startSec, err := parseTimeFlagSec(startInput, "start")
if err != nil {
return "", "", err
}
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
if err != nil {
return "", "", fmt.Errorf("invalid start timestamp: %w", err)
}
hasStart = true
startMillis = startSec + "000"
}
if endInput != "" {
endSec, err := parseTimeFlagSec(endInput, "end")
if err != nil {
return "", "", err
}
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
if err != nil {
return "", "", fmt.Errorf("invalid end timestamp: %w", err)
}
hasEnd = true
endMillis = endSec + "000"
}
if hasStart && hasEnd && startSecInt > endSecInt {
return "", "", fmt.Errorf("start time must be earlier than or equal to end time")
}
return startMillis, endMillis, nil
}
func parseTimeRangeRFC3339(input string) (string, string, error) {
if strings.TrimSpace(input) == "" {
return "", "", nil
}
parts := strings.SplitN(input, ",", 2)
startInput := strings.TrimSpace(parts[0])
endInput := ""
if len(parts) == 2 {
endInput = strings.TrimSpace(parts[1])
}
var startTime, endTime string
var startSecInt, endSecInt int64
var hasStart, hasEnd bool
if startInput != "" {
startSec, err := parseTimeFlagSec(startInput, "start")
if err != nil {
return "", "", err
}
startSecInt, err = strconv.ParseInt(startSec, 10, 64)
if err != nil {
return "", "", fmt.Errorf("invalid start timestamp: %w", err)
}
hasStart = true
startTime = time.Unix(startSecInt, 0).Local().Format(time.RFC3339)
}
if endInput != "" {
endSec, err := parseTimeFlagSec(endInput, "end")
if err != nil {
return "", "", err
}
endSecInt, err = strconv.ParseInt(endSec, 10, 64)
if err != nil {
return "", "", fmt.Errorf("invalid end timestamp: %w", err)
}
hasEnd = true
endTime = time.Unix(endSecInt, 0).Local().Format(time.RFC3339)
}
if hasStart && hasEnd && startSecInt > endSecInt {
return "", "", fmt.Errorf("start time must be earlier than or equal to end time")
}
return startTime, endTime, nil
}
func formatTaskDateTimeMillis(msStr string) string {
if msStr == "" || msStr == "0" {
return ""
}
ms, err := strconv.ParseInt(msStr, 10, 64)
if err != nil {
return ""
}
return time.UnixMilli(ms).Local().Format(time.DateTime)
}
func outputTaskSummary(task map[string]interface{}) map[string]interface{} {
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
out := map[string]interface{}{
"guid": task["guid"],
"summary": task["summary"],
"url": urlVal,
}
if createdAt, _ := task["created_at"].(string); createdAt != "" {
if created := formatTaskDateTimeMillis(createdAt); created != "" {
out["created_at"] = created
}
}
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
out["completed_at"] = completed
}
}
if updatedAt, _ := task["updated_at"].(string); updatedAt != "" {
if updated := formatTaskDateTimeMillis(updatedAt); updated != "" {
out["updated_at"] = updated
}
}
if dueObj, ok := task["due"].(map[string]interface{}); ok {
if tsStr, _ := dueObj["timestamp"].(string); tsStr != "" {
if dueAt := formatTaskDateTimeMillis(tsStr); dueAt != "" {
out["due_at"] = dueAt
}
}
}
return out
}
func outputRelatedTask(task map[string]interface{}) map[string]interface{} {
urlVal, _ := task["url"].(string)
urlVal = truncateTaskURL(urlVal)
out := map[string]interface{}{
"guid": task["guid"],
"summary": task["summary"],
"description": task["description"],
"status": task["status"],
"source": task["source"],
"mode": task["mode"],
"subtask_count": task["subtask_count"],
"tasklists": task["tasklists"],
"url": urlVal,
}
if creator, ok := task["creator"].(map[string]interface{}); ok {
out["creator"] = creator
}
if members, ok := task["members"].([]interface{}); ok {
out["members"] = members
}
if createdAt, _ := task["created_at"].(string); createdAt != "" {
if created := formatTaskDateTimeMillis(createdAt); created != "" {
out["created_at"] = created
}
}
if completedAt, _ := task["completed_at"].(string); completedAt != "" {
if completed := formatTaskDateTimeMillis(completedAt); completed != "" {
out["completed_at"] = completed
}
}
return out
}
func buildTimeRangeFilter(key, start, end string) map[string]interface{} {
timeRange := map[string]interface{}{}
if start != "" {
timeRange["start_time"] = start
}
if end != "" {
timeRange["end_time"] = end
}
if len(timeRange) == 0 {
return nil
}
return map[string]interface{}{key: timeRange}
}
func mergeIntoFilter(dst map[string]interface{}, src map[string]interface{}) {
for k, v := range src {
dst[k] = v
}
}
func requireSearchFilter(query string, filter map[string]interface{}, action string) error {
if strings.TrimSpace(query) != "" {
return nil
}
if len(filter) > 0 {
return nil
}
return WrapTaskError(ErrCodeTaskInvalidParams, "query is empty and no filter is provided", action)
}
func renderRelatedTasksPretty(items []map[string]interface{}, hasMore bool, pageToken string) string {
var b strings.Builder
for i, item := range items {
fmt.Fprintf(&b, "[%d] %v\n", i+1, item["summary"])
fmt.Fprintf(&b, " GUID: %v\n", item["guid"])
if status, _ := item["status"].(string); status != "" {
fmt.Fprintf(&b, " Status: %s\n", status)
}
if created, _ := item["created_at"].(string); created != "" {
fmt.Fprintf(&b, " Created: %s\n", created)
}
if completed, _ := item["completed_at"].(string); completed != "" {
fmt.Fprintf(&b, " Completed: %s\n", completed)
}
if urlVal, _ := item["url"].(string); urlVal != "" {
fmt.Fprintf(&b, " URL: %s\n", urlVal)
}
b.WriteString("\n")
}
if hasMore && pageToken != "" {
fmt.Fprintf(&b, "Next page token: %s\n", pageToken)
}
return b.String()
}

View File

@@ -0,0 +1,286 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
)
func TestSplitAndTrimCSV(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{name: "trim blanks", input: " a, ,b , c ", want: []string{"a", "b", "c"}},
{name: "empty input", input: "", want: []string{}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := splitAndTrimCSV(tt.input)
if len(got) != len(tt.want) {
t.Fatalf("len(splitAndTrimCSV(%q)) = %d, want %d", tt.input, len(got), len(tt.want))
}
for i := range got {
if got[i] != tt.want[i] {
t.Fatalf("splitAndTrimCSV(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i])
}
}
})
}
}
func TestOutputTaskSummary(t *testing.T) {
tests := []struct {
name string
task map[string]interface{}
}{
{
name: "with timestamps and due",
task: map[string]interface{}{
"guid": "task-123",
"summary": "summary",
"url": "https://example.com/task-123&suite_entity_num=t1",
"created_at": "1775174400000",
"due": map[string]interface{}{
"timestamp": "1775174400000",
},
},
},
{
name: "with completed and updated",
task: map[string]interface{}{
"guid": "task-456",
"summary": "done",
"url": "https://example.com/task-456",
"completed_at": "1775174400000",
"updated_at": "1775174400000",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := outputTaskSummary(tt.task)
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
t.Fatalf("unexpected summary output: %#v", got)
}
if got["url"] == "" {
t.Fatalf("expected url in output, got %#v", got)
}
})
}
}
func TestParseTimeRangeMillisAndRequireSearchFilter(t *testing.T) {
timeTests := []struct {
name string
input string
wantErr bool
wantStart string
wantEnd string
}{
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
{name: "invalid input", input: "bad-time", wantErr: true},
{name: "range input", input: "-1d,+1d", wantStart: "non-empty", wantEnd: "non-empty"},
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
}
for _, tt := range timeTests {
t.Run("parse:"+tt.name, func(t *testing.T) {
start, end, err := parseTimeRangeMillis(tt.input)
if tt.wantErr {
if err == nil {
t.Fatalf("parseTimeRangeMillis(%q) expected error, got nil", tt.input)
}
return
}
if err != nil {
t.Fatalf("parseTimeRangeMillis(%q) error = %v", tt.input, err)
}
if tt.wantStart == "" && start != "" {
t.Fatalf("start = %q, want empty", start)
}
if tt.wantEnd == "" && end != "" {
t.Fatalf("end = %q, want empty", end)
}
if tt.wantStart == "non-empty" && start == "" {
t.Fatalf("start should not be empty")
}
if tt.wantEnd == "non-empty" && end == "" {
t.Fatalf("end should not be empty")
}
})
}
filterTests := []struct {
name string
query string
filter map[string]interface{}
wantErr bool
}{
{name: "missing query and filter", query: "", filter: map[string]interface{}{}, wantErr: true},
{name: "query only", query: "query", filter: map[string]interface{}{}, wantErr: false},
{name: "filter only", query: "", filter: map[string]interface{}{"creator_ids": []string{"ou_1"}}, wantErr: false},
}
for _, tt := range filterTests {
t.Run("filter:"+tt.name, func(t *testing.T) {
err := requireSearchFilter(tt.query, tt.filter, "search")
if tt.wantErr && err == nil {
t.Fatalf("expected error, got nil")
}
if !tt.wantErr && err != nil {
t.Fatalf("unexpected error: %v", err)
}
})
}
}
func TestOutputRelatedTaskAndTimeRangeFilter(t *testing.T) {
outputTests := []struct {
name string
task map[string]interface{}
}{
{
name: "full related task",
task: map[string]interface{}{
"guid": "task-123",
"summary": "Related Task",
"description": "desc",
"status": "todo",
"source": 1,
"mode": 2,
"subtask_count": 0,
"tasklists": []interface{}{},
"url": "https://example.com/task-123&suite_entity_num=t1",
"creator": map[string]interface{}{"id": "ou_1"},
"members": []interface{}{map[string]interface{}{"id": "ou_2", "role": "follower"}},
"created_at": "1775174400000",
"completed_at": "1775174400000",
},
},
{
name: "minimal related task",
task: map[string]interface{}{
"guid": "task-456",
"summary": "Minimal",
"url": "https://example.com/task-456",
},
},
}
for _, tt := range outputTests {
t.Run("output:"+tt.name, func(t *testing.T) {
got := outputRelatedTask(tt.task)
if got["guid"] != tt.task["guid"] || got["summary"] != tt.task["summary"] {
t.Fatalf("unexpected related task output: %#v", got)
}
})
}
rangeTests := []struct {
name string
start string
end string
wantNil bool
}{
{name: "empty range", start: "", end: "", wantNil: true},
{name: "full range", start: "1", end: "2", wantNil: false},
}
for _, tt := range rangeTests {
t.Run("range:"+tt.name, func(t *testing.T) {
got := buildTimeRangeFilter("due_time", tt.start, tt.end)
if tt.wantNil && got != nil {
t.Fatalf("expected nil, got %#v", got)
}
if !tt.wantNil && got == nil {
t.Fatalf("expected range filter, got nil")
}
})
}
}
func TestRenderRelatedTasksPretty(t *testing.T) {
tests := []struct {
name string
items []map[string]interface{}
hasMore bool
pageToken string
wantParts []string
}{
{
name: "includes next token",
items: []map[string]interface{}{
{"guid": "task-123", "summary": "Related Task", "url": "https://example.com/task-123"},
},
hasMore: true,
pageToken: "pt_123",
wantParts: []string{"Related Task", "Next page token: pt_123"},
},
{
name: "without next token",
items: []map[string]interface{}{
{"guid": "task-456", "summary": "Another Task"},
},
hasMore: false,
pageToken: "",
wantParts: []string{"Another Task"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := renderRelatedTasksPretty(tt.items, tt.hasMore, tt.pageToken)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
t.Run("parseTimeRangeRFC3339", func(t *testing.T) {
timeTests := []struct {
name string
input string
wantErr bool
wantStart string
wantEnd string
}{
{name: "empty input", input: "", wantStart: "", wantEnd: ""},
{name: "invalid input", input: "bad-time", wantErr: true},
{name: "range input", input: "-1d,+1d", wantStart: "rfc3339", wantEnd: "rfc3339"},
{name: "reversed range fails fast", input: "+1d,-1d", wantErr: true},
}
for _, tt := range timeTests {
t.Run(tt.name, func(t *testing.T) {
start, end, err := parseTimeRangeRFC3339(tt.input)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("parseTimeRangeRFC3339() error = %v", err)
}
if tt.wantStart == "rfc3339" {
if !strings.Contains(start, "T") || !strings.Contains(start, ":") {
t.Fatalf("expected RFC3339 start, got %q", start)
}
} else if start != tt.wantStart {
t.Fatalf("unexpected start: %q", start)
}
if tt.wantEnd == "rfc3339" {
if !strings.Contains(end, "T") || !strings.Contains(end, ":") {
t.Fatalf("expected RFC3339 end, got %q", end)
}
} else if end != tt.wantEnd {
t.Fatalf("unexpected end: %q", end)
}
})
}
})
}
}

View File

@@ -0,0 +1,222 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
taskSearchDefaultPageLimit = 20
taskSearchMaxPageLimit = 40
)
var SearchTask = common.Shortcut{
Service: "task",
Command: "+search",
Description: "search tasks",
Risk: "read",
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
{Name: "page-token", Desc: "page token"},
{Name: "creator", Desc: "creator open_ids, comma-separated"},
{Name: "assignee", Desc: "assignee open_ids, comma-separated"},
{Name: "completed", Type: "bool", Desc: "set true for completed or false for incomplete tasks"},
{Name: "due", Desc: "due time range: start,end (supports ISO/date/relative/ms)"},
{Name: "follower", Desc: "follower open_ids, comma-separated"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTaskSearchBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/search").
Body(body).
Desc("Then GET /open-apis/task/v2/tasks/:guid for each search hit to render standard output")
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildTaskSearchBody(runtime)
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildTaskSearchBody(runtime)
if err != nil {
return err
}
pageLimit := runtime.Int("page-limit")
if pageLimit <= 0 {
pageLimit = taskSearchDefaultPageLimit
}
if runtime.Bool("page-all") {
pageLimit = taskSearchMaxPageLimit
}
if pageLimit > taskSearchMaxPageLimit {
pageLimit = taskSearchMaxPageLimit
}
var rawItems []interface{}
var lastPageToken string
var lastHasMore bool
currentBody := body
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/search",
Body: currentBody,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse task search")
}
}
data, err := HandleTaskApiResult(result, err, "search tasks")
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
rawItems = append(rawItems, items...)
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !lastHasMore || lastPageToken == "" {
break
}
currentBody["page_token"] = lastPageToken
}
enriched := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
itemMap, _ := item.(map[string]interface{})
taskID, _ := itemMap["id"].(string)
if taskID == "" {
continue
}
task, err := getTaskDetail(runtime, taskID)
if err != nil {
metaData, _ := itemMap["meta_data"].(map[string]interface{})
appLink, _ := metaData["app_link"].(string)
enriched = append(enriched, map[string]interface{}{
"guid": taskID,
"url": truncateTaskURL(appLink),
})
continue
}
enriched = append(enriched, outputTaskSummary(task))
}
outData := map[string]interface{}{
"items": enriched,
"page_token": lastPageToken,
"has_more": lastHasMore,
}
runtime.OutFormat(outData, &output.Meta{Count: len(enriched)}, func(w io.Writer) {
if len(enriched) == 0 {
fmt.Fprintln(w, "No tasks found.")
return
}
for i, item := range enriched {
fmt.Fprintf(w, "[%d] %v\n", i+1, item["summary"])
fmt.Fprintf(w, " GUID: %v\n", item["guid"])
if created, _ := item["created_at"].(string); created != "" {
fmt.Fprintf(w, " Created: %s\n", created)
}
if dueAt, _ := item["due_at"].(string); dueAt != "" {
fmt.Fprintf(w, " Due: %s\n", dueAt)
}
if urlVal, _ := item["url"].(string); urlVal != "" {
fmt.Fprintf(w, " URL: %s\n", urlVal)
}
fmt.Fprintln(w)
}
if lastHasMore && lastPageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
}
})
return nil
},
}
func buildTaskSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
filter := map[string]interface{}{}
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
filter["creator_ids"] = ids
}
if ids := splitAndTrimCSV(runtime.Str("assignee")); len(ids) > 0 {
filter["assignee_ids"] = ids
}
if ids := splitAndTrimCSV(runtime.Str("follower")); len(ids) > 0 {
filter["follower_ids"] = ids
}
if runtime.Cmd.Flags().Changed("completed") {
filter["is_completed"] = runtime.Bool("completed")
}
if dueRange := runtime.Str("due"); dueRange != "" {
start, end, err := parseTimeRangeRFC3339(dueRange)
if err != nil {
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid due: %v", err), "build task search")
}
if dueFilter := buildTimeRangeFilter("due_time", start, end); dueFilter != nil {
mergeIntoFilter(filter, dueFilter)
}
}
if err := requireSearchFilter(runtime.Str("query"), filter, "build task search"); err != nil {
return nil, err
}
body := map[string]interface{}{
"query": runtime.Str("query"),
}
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}
func getTaskDetail(runtime *common.RuntimeContext, taskID string) (map[string]interface{}, error) {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID),
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse task detail response: %v", parseErr), "parse task detail")
}
}
data, err := HandleTaskApiResult(result, err, "get task detail "+taskID)
if err != nil {
return nil, err
}
task, _ := data["task"].(map[string]interface{})
if task == nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, "task detail response missing task object", "get task detail")
}
return task, nil
}

View File

@@ -0,0 +1,300 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBuildTaskSearchBody(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantErr bool
check func(*testing.T, map[string]interface{})
}{
{
name: "query creator due and page token",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("query", "release")
_ = cmd.Flags().Set("creator", "ou_a,ou_b")
_ = cmd.Flags().Set("completed", "true")
_ = cmd.Flags().Set("due", "-1d,+1d")
_ = cmd.Flags().Set("page-token", "pt_123")
},
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
dueTime := filter["due_time"].(map[string]interface{})
if body["query"] != "release" || body["page_token"] != "pt_123" {
t.Fatalf("unexpected body: %#v", body)
}
if len(filter["creator_ids"].([]string)) != 2 || filter["is_completed"] != true {
t.Fatalf("unexpected filter: %#v", filter)
}
startTime, _ := dueTime["start_time"].(string)
endTime, _ := dueTime["end_time"].(string)
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
t.Fatalf("unexpected due_time: %#v", dueTime)
}
},
},
{
name: "requires query or filter",
setup: func(cmd *cobra.Command) {},
wantErr: true,
},
{
name: "assignee follower and incomplete",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("assignee", "ou_assignee")
_ = cmd.Flags().Set("follower", "ou_follower")
_ = cmd.Flags().Set("completed", "false")
},
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
if filter["assignee_ids"].([]string)[0] != "ou_assignee" || filter["follower_ids"].([]string)[0] != "ou_follower" {
t.Fatalf("unexpected filter: %#v", filter)
}
if filter["is_completed"] != false {
t.Fatalf("expected is_completed false, got %#v", filter["is_completed"])
}
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("assignee", "", "")
cmd.Flags().String("follower", "", "")
cmd.Flags().Bool("completed", false, "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
body, err := buildTaskSearchBody(runtime)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("buildTaskSearchBody() error = %v", err)
}
tt.check(t, body)
})
}
}
func TestSearchTask_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "valid dry run",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("query", "demo")
_ = cmd.Flags().Set("page-token", "pt_demo")
},
wantParts: []string{"POST /open-apis/task/v2/tasks/search", `"query":"demo"`},
},
{
name: "dry run error on invalid due",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("due", "bad-time")
},
wantParts: []string{"error:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("assignee", "", "")
cmd.Flags().String("follower", "", "")
cmd.Flags().Bool("completed", false, "")
cmd.Flags().String("due", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
if !strings.Contains(tt.name, "error") {
if err := SearchTask.Validate(nil, runtime); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
out := SearchTask.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
func TestSearchTask_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantParts []string
}{
{
name: "json success",
args: []string{"+search", "--query", "release", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{"id": "task-123", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-123"}},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-123",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{"guid": "task-123", "summary": "Search Result", "created_at": "1775174400000", "url": "https://example.com/task-123"},
},
},
})
},
wantParts: []string{`"guid": "task-123"`, `"summary": "Search Result"`},
},
{
name: "fallback to app link",
args: []string{"+search", "--query", "fallback", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{
map[string]interface{}{"id": "task-999", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-999&suite_entity_num=t999"}},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-999",
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
})
},
wantParts: []string{`"guid": "task-999"`, `"url": "https://example.com/task-999"`},
},
{
name: "empty pretty with pagination",
args: []string{"+search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
},
})
},
wantParts: []string{"No tasks found."},
},
{
name: "pretty with next page token",
args: []string{"+search", "--query", "pretty", "--as", "bot", "--format", "pretty", "--page-limit", "1"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": true,
"page_token": "pt_next",
"items": []interface{}{
map[string]interface{}{"id": "task-321", "meta_data": map[string]interface{}{"app_link": "https://example.com/task-321"}},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasks/task-321",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{"guid": "task-321", "summary": "Pretty Search", "url": "https://example.com/task-321"},
},
},
})
},
wantParts: []string{"Pretty Search", "Next page token: pt_next"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
s := SearchTask
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
var SetAncestorTask = common.Shortcut{
Service: "task",
Command: "+set-ancestor",
Description: "set or clear a task ancestor",
Risk: "write",
Scopes: []string{"task:task:write"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "task-id", Desc: "task guid to update", Required: true},
{Name: "ancestor-id", Desc: "ancestor task guid; omit to make it independent"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
taskID := url.PathEscape(runtime.Str("task-id"))
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/" + taskID + "/set_ancestor_task").
Params(map[string]interface{}{"user_id_type": "open_id"}).
Body(buildSetAncestorBody(runtime.Str("ancestor-id")))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
taskID := runtime.Str("task-id")
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasks/" + url.PathEscape(taskID) + "/set_ancestor_task",
QueryParams: queryParams,
Body: buildSetAncestorBody(runtime.Str("ancestor-id")),
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "set ancestor task")
}
}
if _, err = HandleTaskApiResult(result, err, "set ancestor task"); err != nil {
return err
}
outData := map[string]interface{}{
"ok": true,
"data": map[string]interface{}{
"guid": taskID,
},
}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintf(w, "✅ Task ancestor updated successfully!\nTask ID: %s\n", taskID)
if ancestorID := runtime.Str("ancestor-id"); ancestorID != "" {
fmt.Fprintf(w, "Ancestor ID: %s\n", ancestorID)
} else {
fmt.Fprintln(w, "Ancestor cleared: task is now independent")
}
})
return nil
},
}
func buildSetAncestorBody(ancestorID string) map[string]interface{} {
if ancestorID == "" {
return map[string]interface{}{}
}
return map[string]interface{}{
"ancestor_guid": ancestorID,
}
}

View File

@@ -0,0 +1,166 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBuildSetAncestorBody(t *testing.T) {
tests := []struct {
name string
ancestorID string
want map[string]interface{}
}{
{name: "empty ancestor", ancestorID: "", want: map[string]interface{}{}},
{name: "set ancestor", ancestorID: "guid_2", want: map[string]interface{}{"ancestor_guid": "guid_2"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := buildSetAncestorBody(tt.ancestorID)
if len(got) != len(tt.want) {
t.Fatalf("len(buildSetAncestorBody(%q)) = %d, want %d", tt.ancestorID, len(got), len(tt.want))
}
for k, want := range tt.want {
if got[k] != want {
t.Fatalf("buildSetAncestorBody(%q)[%q] = %#v, want %#v", tt.ancestorID, k, got[k], want)
}
}
})
}
}
func TestSetAncestorTask_DryRun(t *testing.T) {
tests := []struct {
name string
taskID string
ancestor string
wantParts []string
}{
{
name: "with ancestor",
taskID: "task-123",
ancestor: "task-456",
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task", `"ancestor_guid":"task-456"`},
},
{
name: "clear ancestor",
taskID: "task-123",
wantParts: []string{"POST /open-apis/task/v2/tasks/task-123/set_ancestor_task"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("task-id", "", "")
cmd.Flags().String("ancestor-id", "", "")
_ = cmd.Flags().Set("task-id", tt.taskID)
if tt.ancestor != "" {
_ = cmd.Flags().Set("ancestor-id", tt.ancestor)
}
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "bot")
out := SetAncestorTask.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
func TestSetAncestorTask_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantErr bool
wantParts []string
}{
{
name: "json output with ancestor",
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{`"guid": "task-123"`},
},
{
name: "pretty output clears ancestor",
args: []string{"+set-ancestor", "--task-id", "task-123", "--as", "bot", "--format", "pretty"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{"Ancestor cleared", "Task ID: task-123"},
},
{
name: "api-level error (code!=0) returns error",
args: []string{"+set-ancestor", "--task-id", "task-123", "--ancestor-id", "task-456", "--as", "bot", "--format", "pretty"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-123/set_ancestor_task",
Body: map[string]interface{}{
"code": 10003,
"msg": "permission denied",
},
})
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
err := runMountedTaskShortcut(t, SetAncestorTask, tt.args, f, stdout)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
if out := stdout.String(); out != "" {
t.Fatalf("expected empty stdout on error, got: %s", out)
}
return
}
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/shortcuts/common"
)
var SubscribeTaskEvent = common.Shortcut{
Service: "task",
Command: "+subscribe-event",
Description: "subscribe to task events",
Risk: "write",
Scopes: []string{"task:task:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
POST("/open-apis/task/v2/task_v2/task_subscription").
Params(map[string]interface{}{"user_id_type": "open_id"})
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/task_v2/task_subscription",
QueryParams: queryParams,
})
// DoAPI may return HTTP 200 while the JSON body contains a non-zero business "code".
// Parse and validate the envelope to avoid false-success output.
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "subscribe task events")
}
}
if _, err := HandleTaskApiResult(result, err, "subscribe task events"); err != nil {
return err
}
outData := map[string]interface{}{"ok": true}
runtime.OutFormat(outData, nil, func(w io.Writer) {
fmt.Fprintln(w, "✅ Task event subscription created successfully!")
})
return nil
},
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestSubscribeTaskEvent(t *testing.T) {
tests := []struct {
name string
mode string
args []string
register func(*httpmock.Registry)
wantErr bool
wantParts []string
}{
{
name: "execute json (user identity)",
mode: "execute",
args: []string{"+subscribe-event", "--as", "user", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{`"ok": true`},
},
{
name: "execute json (bot identity)",
mode: "execute",
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{},
},
})
},
wantParts: []string{`"ok": true`},
},
{
name: "execute api error",
mode: "execute",
args: []string{"+subscribe-event", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/task_v2/task_subscription",
Body: map[string]interface{}{
"code": 401,
"msg": "Unauthorized",
"error": map[string]interface{}{
"log_id": "test-log-id",
},
},
})
},
wantErr: true,
wantParts: []string{"Unauthorized"},
},
{
name: "dry run",
mode: "dryrun",
wantParts: []string{"POST /open-apis/task/v2/task_v2/task_subscription"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
switch tt.mode {
case "execute":
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
if tt.register != nil {
tt.register(reg)
}
err := runMountedTaskShortcut(t, SubscribeTaskEvent, tt.args, f, stdout)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
out := err.Error()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("error missing %q: %s", want, out)
}
}
return
}
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
case "dryrun":
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "test"}, taskTestConfig(t), "user")
out := SubscribeTaskEvent.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
}
})
}
}

View File

@@ -0,0 +1,209 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const (
tasklistSearchDefaultPageLimit = 20
tasklistSearchMaxPageLimit = 40
)
var SearchTasklist = common.Shortcut{
Service: "task",
Command: "+tasklist-search",
Description: "search tasklists",
Risk: "read",
Scopes: []string{"task:tasklist:read"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "query", Desc: "search keyword"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (max 40)"},
{Name: "page-limit", Type: "int", Default: "20", Desc: "max page limit (default 20, max 40)"},
{Name: "page-token", Desc: "page token"},
{Name: "creator", Desc: "creator open_ids, comma-separated"},
{Name: "create-time", Desc: "create time range: start,end (supports ISO/date/relative/ms)"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
body, err := buildTasklistSearchBody(runtime)
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasklists/search").
Body(body).
Desc("Then GET /open-apis/task/v2/tasklists/:guid for each search hit to render standard output")
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
_, err := buildTasklistSearchBody(runtime)
return err
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
body, err := buildTasklistSearchBody(runtime)
if err != nil {
return err
}
pageLimit := runtime.Int("page-limit")
if pageLimit <= 0 {
pageLimit = tasklistSearchDefaultPageLimit
}
if runtime.Bool("page-all") {
pageLimit = tasklistSearchMaxPageLimit
}
if pageLimit > tasklistSearchMaxPageLimit {
pageLimit = tasklistSearchMaxPageLimit
}
var rawItems []interface{}
var lastPageToken string
var lastHasMore bool
currentBody := body
for page := 0; page < pageLimit; page++ {
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodPost,
ApiPath: "/open-apis/task/v2/tasklists/search",
Body: currentBody,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse response: %v", parseErr), "parse tasklist search")
}
}
data, err := HandleTaskApiResult(result, err, "search tasklists")
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
rawItems = append(rawItems, items...)
lastHasMore, _ = data["has_more"].(bool)
lastPageToken, _ = data["page_token"].(string)
if !lastHasMore || lastPageToken == "" {
break
}
currentBody["page_token"] = lastPageToken
}
tasklists := make([]map[string]interface{}, 0, len(rawItems))
for _, item := range rawItems {
itemMap, _ := item.(map[string]interface{})
tasklistID, _ := itemMap["id"].(string)
if tasklistID == "" {
continue
}
tasklist, err := getTasklistDetail(runtime, tasklistID)
if err != nil {
// Keep a stable identifier and avoid rendering "<nil>" in pretty output.
tasklists = append(tasklists, map[string]interface{}{
"guid": tasklistID,
"name": fmt.Sprintf("(unknown tasklist: %s)", tasklistID),
})
continue
}
urlVal, _ := tasklist["url"].(string)
urlVal = truncateTaskURL(urlVal)
tasklists = append(tasklists, map[string]interface{}{
"guid": tasklist["guid"],
"name": tasklist["name"],
"url": urlVal,
"creator": tasklist["creator"],
})
}
outData := map[string]interface{}{
"items": tasklists,
"page_token": lastPageToken,
"has_more": lastHasMore,
}
runtime.OutFormat(outData, &output.Meta{Count: len(tasklists)}, func(w io.Writer) {
if len(tasklists) == 0 {
fmt.Fprintln(w, "No tasklists found.")
return
}
for i, tasklist := range tasklists {
fmt.Fprintf(w, "[%d] %v\n", i+1, tasklist["name"])
fmt.Fprintf(w, " GUID: %v\n", tasklist["guid"])
if urlVal, _ := tasklist["url"].(string); urlVal != "" {
fmt.Fprintf(w, " URL: %s\n", urlVal)
}
fmt.Fprintln(w)
}
if lastHasMore && lastPageToken != "" {
fmt.Fprintf(w, "Next page token: %s\n", lastPageToken)
}
})
return nil
},
}
func buildTasklistSearchBody(runtime *common.RuntimeContext) (map[string]interface{}, error) {
filter := map[string]interface{}{}
if ids := splitAndTrimCSV(runtime.Str("creator")); len(ids) > 0 {
filter["user_id"] = ids
}
if createTime := runtime.Str("create-time"); createTime != "" {
start, end, err := parseTimeRangeRFC3339(createTime)
if err != nil {
return nil, WrapTaskError(ErrCodeTaskInvalidParams, fmt.Sprintf("invalid create-time: %v", err), "build tasklist search")
}
if timeFilter := buildTimeRangeFilter("create_time", start, end); timeFilter != nil {
mergeIntoFilter(filter, timeFilter)
}
}
if err := requireSearchFilter(runtime.Str("query"), filter, "build tasklist search"); err != nil {
return nil, err
}
body := map[string]interface{}{
"query": runtime.Str("query"),
}
if len(filter) > 0 {
body["filter"] = filter
}
if pageToken := runtime.Str("page-token"); pageToken != "" {
body["page_token"] = pageToken
}
return body, nil
}
func getTasklistDetail(runtime *common.RuntimeContext, tasklistID string) (map[string]interface{}, error) {
queryParams := make(larkcore.QueryParams)
queryParams.Set("user_id_type", "open_id")
apiResp, err := runtime.DoAPI(&larkcore.ApiReq{
HttpMethod: http.MethodGet,
ApiPath: "/open-apis/task/v2/tasklists/" + url.PathEscape(tasklistID),
QueryParams: queryParams,
})
var result map[string]interface{}
if err == nil {
if parseErr := json.Unmarshal(apiResp.RawBody, &result); parseErr != nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, fmt.Sprintf("failed to parse tasklist detail response: %v", parseErr), "parse tasklist detail")
}
}
data, err := HandleTaskApiResult(result, err, "get tasklist detail "+tasklistID)
if err != nil {
return nil, err
}
tasklist, _ := data["tasklist"].(map[string]interface{})
if tasklist == nil {
return nil, WrapTaskError(ErrCodeTaskInternalError, "tasklist detail response missing tasklist object", "get tasklist detail")
}
return tasklist, nil
}

View File

@@ -0,0 +1,263 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
)
func TestBuildTasklistSearchBody(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantErr bool
check func(*testing.T, map[string]interface{})
}{
{
name: "creator create-time and page token",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("creator", "ou_creator")
_ = cmd.Flags().Set("create-time", "-7d,+0d")
_ = cmd.Flags().Set("page-token", "pt_tl")
},
check: func(t *testing.T, body map[string]interface{}) {
filter := body["filter"].(map[string]interface{})
createTime := filter["create_time"].(map[string]interface{})
if body["page_token"] != "pt_tl" {
t.Fatalf("unexpected body: %#v", body)
}
if filter["user_id"].([]string)[0] != "ou_creator" {
t.Fatalf("unexpected filter: %#v", filter)
}
startTime, _ := createTime["start_time"].(string)
endTime, _ := createTime["end_time"].(string)
if startTime == "" || endTime == "" || !strings.Contains(startTime, "T") || !strings.Contains(endTime, "T") {
t.Fatalf("unexpected create_time: %#v", createTime)
}
},
},
{
name: "requires query or filter",
setup: func(cmd *cobra.Command) {},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("create-time", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
body, err := buildTasklistSearchBody(runtime)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("buildTasklistSearchBody() error = %v", err)
}
tt.check(t, body)
})
}
}
func TestSearchTasklist_DryRun(t *testing.T) {
tests := []struct {
name string
setup func(*cobra.Command)
wantParts []string
}{
{
name: "valid dry run",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("query", "Q2")
_ = cmd.Flags().Set("page-token", "pt_tl")
},
wantParts: []string{"POST /open-apis/task/v2/tasklists/search", `"query":"Q2"`},
},
{
name: "dry run error on invalid create time",
setup: func(cmd *cobra.Command) {
_ = cmd.Flags().Set("create-time", "bad-time")
},
wantParts: []string{"error:"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("query", "", "")
cmd.Flags().String("creator", "", "")
cmd.Flags().String("create-time", "", "")
cmd.Flags().String("page-token", "", "")
tt.setup(cmd)
runtime := common.TestNewRuntimeContextWithIdentity(cmd, taskTestConfig(t), "user")
if !strings.Contains(tt.name, "error") {
if err := SearchTasklist.Validate(nil, runtime); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}
out := SearchTasklist.DryRun(nil, runtime).Format()
for _, want := range tt.wantParts {
if !strings.Contains(out, want) {
t.Fatalf("dry run output missing %q: %s", want, out)
}
}
})
}
}
func TestSearchTasklist_Execute(t *testing.T) {
tests := []struct {
name string
args []string
register func(*httpmock.Registry)
wantParts []string
}{
{
name: "json success",
args: []string{"+tasklist-search", "--query", "Q2", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{map[string]interface{}{"id": "tl-123"}},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-123",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"tasklist": map[string]interface{}{"guid": "tl-123", "name": "Q2 Plan", "url": "https://example.com/tl-123"},
},
},
})
},
wantParts: []string{`"guid": "tl-123"`, `"name": "Q2 Plan"`},
},
{
name: "fallback on detail error",
args: []string{"+tasklist-search", "--query", "fallback", "--as", "bot", "--format", "json"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-fallback",
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
})
},
wantParts: []string{`"guid": "tl-fallback"`},
},
{
name: "pretty fallback avoids nil name",
args: []string{"+tasklist-search", "--query", "fallback-pretty", "--as", "bot", "--format", "pretty"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{
"has_more": false,
"page_token": "",
"items": []interface{}{map[string]interface{}{"id": "tl-fallback"}},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/task/v2/tasklists/tl-fallback",
Body: map[string]interface{}{"code": 99991663, "msg": "not found"},
})
},
wantParts: []string{"(unknown tasklist: tl-fallback)", "GUID: tl-fallback"},
},
{
name: "empty pretty with pagination",
args: []string{"+tasklist-search", "--query", "none", "--as", "bot", "--format", "pretty", "--page-limit", "2"},
register: func(reg *httpmock.Registry) {
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": true, "page_token": "pt_2", "items": []interface{}{}},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasklists/search",
Body: map[string]interface{}{
"code": 0,
"msg": "success",
"data": map[string]interface{}{"has_more": false, "page_token": "", "items": []interface{}{}},
},
})
},
wantParts: []string{"No tasklists found."},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
tt.register(reg)
s := SearchTasklist
s.AuthTypes = []string{"bot", "user"}
err := runMountedTaskShortcut(t, s, tt.args, f, stdout)
if err != nil {
t.Fatalf("runMountedTaskShortcut() error = %v", err)
}
out := stdout.String()
outNorm := strings.ReplaceAll(out, `":"`, `": "`)
for _, want := range tt.wantParts {
if !strings.Contains(out, want) && !strings.Contains(outNorm, want) {
t.Fatalf("output missing %q: %s", want, out)
}
}
})
}
}

View File

@@ -30,6 +30,7 @@ var AddTaskToTasklist = common.Shortcut{
Flags: []common.Flag{
{Name: "tasklist-id", Desc: "tasklist id", Required: true},
{Name: "task-id", Desc: "task id (comma-separated for multiple)", Required: true},
{Name: "section-guid", Desc: "section guid"},
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -40,6 +41,10 @@ var AddTaskToTasklist = common.Shortcut{
"tasklist_guid": extractTasklistGuid(runtime.Str("tasklist-id")),
}
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
body["section_guid"] = sectionGuid
}
return common.NewDryRunAPI().
POST("/open-apis/task/v2/tasks/" + taskId + "/add_tasklist").
Params(map[string]interface{}{"user_id_type": "open_id"}).
@@ -57,6 +62,10 @@ var AddTaskToTasklist = common.Shortcut{
"tasklist_guid": tasklistGuid,
}
if sectionGuid := strings.TrimSpace(runtime.Str("section-guid")); sectionGuid != "" {
body["section_guid"] = sectionGuid
}
var successful []map[string]interface{}
var failed []map[string]interface{}

View File

@@ -0,0 +1,43 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package task
import (
"strings"
"testing"
"github.com/larksuite/cli/internal/httpmock"
)
func TestAddTaskToTasklist_Success(t *testing.T) {
f, stdout, _, reg := taskShortcutTestFactory(t)
warmTenantToken(t, f, reg)
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/task/v2/tasks/task-1/add_tasklist",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"task": map[string]interface{}{
"guid": "task-1",
},
},
},
})
s := AddTaskToTasklist
s.AuthTypes = []string{"bot", "user"}
args := []string{"+tasklist-task-add", "--tasklist-id", "tl-123", "--task-id", "task-1", "--section-guid", "sec-456", "--as", "bot", "--format", "json"}
err := runMountedTaskShortcut(t, s, args, f, stdout)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"tasklist_guid":"tl-123"`) && !strings.Contains(out, `"tasklist_guid": "tl-123"`) {
t.Errorf("expected tasklist_guid in output, got: %s", out)
}
}

View File

@@ -8,6 +8,7 @@ import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all wiki shortcuts.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
WikiMove,
WikiNodeCreate,
}
}

671
shortcuts/wiki/wiki_move.go Normal file
View File

@@ -0,0 +1,671 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
var (
wikiMovePollAttempts = 30
wikiMovePollInterval = 2 * time.Second
)
const (
wikiMoveModeNode = "node"
wikiMoveModeDocsToWiki = "docs_to_wiki"
)
var wikiMoveObjectTypes = []string{
"doc",
"sheet",
"bitable",
"mindnote",
"docx",
"file",
"slides",
}
// WikiMove moves an existing wiki node inside Wiki or migrates a Drive
// document into Wiki with bounded polling for async task completion.
var WikiMove = common.Shortcut{
Service: "wiki",
Command: "+move",
Description: "Move a wiki node, or move a Drive document into Wiki",
Risk: "write",
Scopes: []string{"wiki:node:move", "wiki:node:read", "wiki:space:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "node-token", Desc: "wiki node token to move inside Wiki"},
{Name: "source-space-id", Desc: "source wiki space ID for --node-token; if omitted, it is resolved from the node token"},
{Name: "target-space-id", Desc: "target wiki space ID; required for docs-to-wiki, optional for node move when --target-parent-token is set"},
{Name: "target-parent-token", Desc: "target parent wiki node token; if omitted for docs-to-wiki, the document is moved to the target space root"},
{Name: "obj-type", Desc: "Drive document type for docs-to-wiki mode", Enum: wikiMoveObjectTypes},
{Name: "obj-token", Desc: "Drive document token for docs-to-wiki mode"},
{Name: "apply", Type: "bool", Desc: "submit a move request when the caller lacks permission to move the document immediately"},
},
Tips: []string{
"Use --node-token to move an existing wiki node inside or across wiki spaces.",
"Use --obj-type and --obj-token to move a Drive document into Wiki.",
"If docs-to-wiki returns a long-running task, this command polls for a bounded window and then prints a follow-up drive +task_result command.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := readWikiMoveSpec(runtime)
// `my_library` is a per-user personal-library alias; it has no meaning
// for a tenant_access_token (--as bot), so reject early with a clear
// hint instead of letting the API return a confusing error.
if runtime.As().IsBot() && spec.TargetSpaceID == wikiMyLibrarySpaceID {
return output.ErrValidation("--target-space-id my_library is a per-user personal library alias and cannot be used with --as bot; resolve it to a real space_id first via `lark-cli wiki spaces get --params '{\"space_id\":\"my_library\"}' --as user`")
}
return validateWikiMoveSpec(spec)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return buildWikiMoveDryRun(readWikiMoveSpec(runtime))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := readWikiMoveSpec(runtime)
fmt.Fprintf(runtime.IO().ErrOut, "Running wiki move (%s)...\n", spec.Mode())
out, err := runWikiMove(ctx, wikiMoveAPI{runtime: runtime}, runtime, spec)
if err != nil {
return err
}
runtime.Out(out, nil)
return nil
},
}
type wikiMoveSpec struct {
NodeToken string
SourceSpaceID string
TargetSpaceID string
TargetParentToken string
ObjType string
ObjToken string
Apply bool
}
func (spec wikiMoveSpec) Mode() string {
if spec.NodeToken != "" {
return wikiMoveModeNode
}
return wikiMoveModeDocsToWiki
}
func (spec wikiMoveSpec) NodeMoveBody() map[string]interface{} {
body := map[string]interface{}{}
if spec.TargetParentToken != "" {
body["target_parent_token"] = spec.TargetParentToken
}
if spec.TargetSpaceID != "" {
body["target_space_id"] = spec.TargetSpaceID
}
return body
}
func (spec wikiMoveSpec) DocsToWikiBody() map[string]interface{} {
body := map[string]interface{}{
"obj_type": spec.ObjType,
"obj_token": spec.ObjToken,
}
if spec.TargetParentToken != "" {
body["parent_wiki_token"] = spec.TargetParentToken
}
if spec.Apply {
body["apply"] = true
}
return body
}
type wikiMoveTaskResult struct {
Node *wikiNodeRecord
Status int
StatusMsg string
}
type wikiMoveTaskStatus struct {
TaskID string
MoveResults []wikiMoveTaskResult
}
func (s wikiMoveTaskStatus) Ready() bool {
if len(s.MoveResults) == 0 {
return false
}
for _, result := range s.MoveResults {
if result.Status != 0 {
return false
}
}
return true
}
func (s wikiMoveTaskStatus) Failed() bool {
for _, result := range s.MoveResults {
if result.Status < 0 {
return true
}
}
return false
}
func (s wikiMoveTaskStatus) Pending() bool {
return !s.Ready() && !s.Failed()
}
func (s wikiMoveTaskStatus) FirstResult() *wikiMoveTaskResult {
if len(s.MoveResults) == 0 {
return nil
}
return &s.MoveResults[0]
}
// primaryResult picks the most informative move_result for top-level status
// surfacing: prefer a failing entry so multi-doc tasks don't mask failures
// behind an earlier success, then a still-processing entry, and finally fall
// back to the first entry.
func (s wikiMoveTaskStatus) primaryResult() *wikiMoveTaskResult {
for i := range s.MoveResults {
if s.MoveResults[i].Status < 0 {
return &s.MoveResults[i]
}
}
for i := range s.MoveResults {
if s.MoveResults[i].Status > 0 {
return &s.MoveResults[i]
}
}
return s.FirstResult()
}
func (s wikiMoveTaskStatus) PrimaryStatusCode() int {
if r := s.primaryResult(); r != nil {
return r.Status
}
return 1
}
func (s wikiMoveTaskStatus) PrimaryStatusLabel() string {
if r := s.primaryResult(); r != nil {
if msg := strings.TrimSpace(r.StatusMsg); msg != "" {
return msg
}
}
switch {
case s.Ready():
return "success"
case s.Failed():
return "failure"
default:
return "processing"
}
}
type wikiMoveDocsResponse struct {
WikiToken string
TaskID string
Applied bool
}
type wikiMoveClient interface {
GetNode(ctx context.Context, token string) (*wikiNodeRecord, error)
MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error)
MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error)
GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error)
}
type wikiMoveAPI struct {
runtime *common.RuntimeContext
}
func (api wikiMoveAPI) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
data, err := api.runtime.CallAPI(
"GET",
"/open-apis/wiki/v2/spaces/get_node",
map[string]interface{}{"token": token},
nil,
)
if err != nil {
return nil, err
}
return parseWikiNodeRecord(common.GetMap(data, "node"))
}
func (api wikiMoveAPI) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) {
data, err := api.runtime.CallAPI(
"POST",
fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/%s/move",
validate.EncodePathSegment(sourceSpaceID),
validate.EncodePathSegment(spec.NodeToken),
),
nil,
spec.NodeMoveBody(),
)
if err != nil {
return nil, err
}
return parseWikiNodeRecord(common.GetMap(data, "node"))
}
func (api wikiMoveAPI) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) {
data, err := api.runtime.CallAPI(
"POST",
fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki",
validate.EncodePathSegment(targetSpaceID),
),
nil,
spec.DocsToWikiBody(),
)
if err != nil {
return nil, err
}
return &wikiMoveDocsResponse{
WikiToken: common.GetString(data, "wiki_token"),
TaskID: common.GetString(data, "task_id"),
Applied: common.GetBool(data, "applied"),
}, nil
}
func (api wikiMoveAPI) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) {
data, err := api.runtime.CallAPI(
"GET",
fmt.Sprintf("/open-apis/wiki/v2/tasks/%s", validate.EncodePathSegment(taskID)),
map[string]interface{}{"task_type": "move"},
nil,
)
if err != nil {
return wikiMoveTaskStatus{}, err
}
return parseWikiMoveTaskStatus(taskID, common.GetMap(data, "task"))
}
func readWikiMoveSpec(runtime *common.RuntimeContext) wikiMoveSpec {
return wikiMoveSpec{
NodeToken: strings.TrimSpace(runtime.Str("node-token")),
SourceSpaceID: strings.TrimSpace(runtime.Str("source-space-id")),
TargetSpaceID: strings.TrimSpace(runtime.Str("target-space-id")),
TargetParentToken: strings.TrimSpace(runtime.Str("target-parent-token")),
ObjType: strings.ToLower(strings.TrimSpace(runtime.Str("obj-type"))),
ObjToken: strings.TrimSpace(runtime.Str("obj-token")),
Apply: runtime.Bool("apply"),
}
}
func validateWikiMoveSpec(spec wikiMoveSpec) error {
if err := validateOptionalResourceName(spec.NodeToken, "--node-token"); err != nil {
return err
}
if err := validateOptionalResourceName(spec.SourceSpaceID, "--source-space-id"); err != nil {
return err
}
if err := validateOptionalResourceName(spec.TargetSpaceID, "--target-space-id"); err != nil {
return err
}
if err := validateOptionalResourceName(spec.TargetParentToken, "--target-parent-token"); err != nil {
return err
}
if err := validateOptionalResourceName(spec.ObjToken, "--obj-token"); err != nil {
return err
}
if spec.NodeToken != "" {
if spec.ObjType != "" || spec.ObjToken != "" || spec.Apply {
return output.ErrValidation("--node-token cannot be combined with --obj-type, --obj-token, or --apply")
}
if spec.TargetParentToken == "" && spec.TargetSpaceID == "" {
return output.ErrValidation("--target-parent-token and --target-space-id cannot both be empty for wiki node move")
}
return nil
}
if spec.SourceSpaceID != "" {
return output.ErrValidation("--source-space-id can only be used with --node-token")
}
if spec.ObjType == "" && spec.ObjToken == "" && !spec.Apply {
return output.ErrValidation("provide --node-token for wiki node move, or provide --obj-type and --obj-token for docs-to-wiki move")
}
if spec.ObjType == "" {
return output.ErrValidation("--obj-type is required for docs-to-wiki move")
}
if spec.ObjToken == "" {
return output.ErrValidation("--obj-token is required for docs-to-wiki move")
}
if spec.TargetSpaceID == "" {
return output.ErrValidation("--target-space-id is required for docs-to-wiki move")
}
return nil
}
func buildWikiMoveDryRun(spec wikiMoveSpec) *common.DryRunAPI {
dry := common.NewDryRunAPI()
switch spec.Mode() {
case wikiMoveModeNode:
step := 1
switch {
case spec.SourceSpaceID == "" && spec.TargetParentToken != "":
dry.Desc("3-step orchestration: resolve source node -> resolve target parent -> move wiki node")
case spec.SourceSpaceID == "":
dry.Desc("2-step orchestration: resolve source node -> move wiki node")
case spec.TargetParentToken != "":
dry.Desc("2-step orchestration: resolve target parent -> move wiki node")
default:
dry.Desc("1-step request: move wiki node")
}
if spec.SourceSpaceID == "" {
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc(fmt.Sprintf("[%d] Resolve source space from node token", step)).
Params(map[string]interface{}{"token": spec.NodeToken})
step++
}
if spec.TargetParentToken != "" {
dry.GET("/open-apis/wiki/v2/spaces/get_node").
Desc(fmt.Sprintf("[%d] Resolve target parent node", step)).
Params(map[string]interface{}{"token": spec.TargetParentToken})
step++
}
dry.POST(fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/%s/move",
dryRunWikiMoveSourceSpaceID(spec),
validate.EncodePathSegment(spec.NodeToken),
)).
Desc(fmt.Sprintf("[%d] Move wiki node", step)).
Body(spec.NodeMoveBody())
case wikiMoveModeDocsToWiki:
dry.Desc("2-step orchestration: move Drive document into Wiki -> poll wiki task result when task_id is returned")
dry.POST(fmt.Sprintf(
"/open-apis/wiki/v2/spaces/%s/nodes/move_docs_to_wiki",
dryRunWikiMoveTargetSpaceID(spec),
)).
Desc("[1] Move Drive document into Wiki").
Body(spec.DocsToWikiBody())
dry.GET("/open-apis/wiki/v2/tasks/:task_id").
Desc("[2] Poll wiki move task result when async").
Set("task_id", "<task_id>").
Params(map[string]interface{}{"task_type": "move"})
default:
dry.Set("error", "unknown wiki move mode")
}
return dry
}
func dryRunWikiMoveSourceSpaceID(spec wikiMoveSpec) string {
if spec.SourceSpaceID != "" {
return validate.EncodePathSegment(spec.SourceSpaceID)
}
return "<resolved_source_space_id>"
}
func dryRunWikiMoveTargetSpaceID(spec wikiMoveSpec) string {
if spec.TargetSpaceID != "" {
return validate.EncodePathSegment(spec.TargetSpaceID)
}
return "<target_space_id>"
}
func runWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, spec wikiMoveSpec) (map[string]interface{}, error) {
switch spec.Mode() {
case wikiMoveModeNode:
return runWikiNodeMove(ctx, client, spec)
case wikiMoveModeDocsToWiki:
return runWikiDocsToWikiMove(ctx, client, runtime, spec)
default:
return nil, output.ErrValidation("unknown wiki move mode")
}
}
func runWikiNodeMove(ctx context.Context, client wikiMoveClient, spec wikiMoveSpec) (map[string]interface{}, error) {
sourceSpaceID, targetSpaceID, err := resolveWikiNodeMoveSpaces(ctx, client, spec)
if err != nil {
return nil, err
}
node, err := client.MoveNode(ctx, sourceSpaceID, spec)
if err != nil {
return nil, err
}
out := map[string]interface{}{
"mode": wikiMoveModeNode,
"source_space_id": sourceSpaceID,
"target_space_id": targetSpaceID,
}
appendWikiNodeOutput(out, node)
return out, nil
}
func resolveWikiNodeMoveSpaces(ctx context.Context, client wikiMoveClient, spec wikiMoveSpec) (string, string, error) {
// Node move requests may start from just a node token and/or a target parent.
// Resolve both ends up front so we can fail on space mismatches before sending
// the mutation request.
sourceSpaceID := spec.SourceSpaceID
if sourceSpaceID == "" {
sourceNode, err := client.GetNode(ctx, spec.NodeToken)
if err != nil {
return "", "", err
}
sourceSpaceID, err = requireWikiNodeSpaceID(sourceNode)
if err != nil {
return "", "", err
}
}
targetSpaceID := spec.TargetSpaceID
if spec.TargetParentToken != "" {
targetParent, err := client.GetNode(ctx, spec.TargetParentToken)
if err != nil {
return "", "", err
}
parentSpaceID, err := requireWikiNodeSpaceID(targetParent)
if err != nil {
return "", "", err
}
if targetSpaceID == "" {
targetSpaceID = parentSpaceID
} else if targetSpaceID != parentSpaceID {
return "", "", output.ErrValidation(
"--target-space-id %q does not match target parent node space %q",
spec.TargetSpaceID,
parentSpaceID,
)
}
}
if targetSpaceID == "" {
targetSpaceID = sourceSpaceID
}
return sourceSpaceID, targetSpaceID, nil
}
func runWikiDocsToWikiMove(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, spec wikiMoveSpec) (map[string]interface{}, error) {
response, err := client.MoveDocsToWiki(ctx, spec.TargetSpaceID, spec)
if err != nil {
return nil, err
}
out := map[string]interface{}{
"mode": wikiMoveModeDocsToWiki,
"obj_type": spec.ObjType,
"obj_token": spec.ObjToken,
"target_space_id": spec.TargetSpaceID,
"target_parent_token": spec.TargetParentToken,
}
// move_docs_to_wiki has three success-shaped responses: immediate completion,
// approval-request submission, or an async task that must be polled.
switch {
case response.WikiToken != "":
out["ready"] = true
out["failed"] = false
out["wiki_token"] = response.WikiToken
out["node_token"] = response.WikiToken
return out, nil
case response.Applied:
out["ready"] = false
out["failed"] = false
out["applied"] = true
out["status_msg"] = "move request submitted for approval"
return out, nil
case response.TaskID != "":
fmt.Fprintf(runtime.IO().ErrOut, "Docs-to-wiki move is async, polling task %s...\n", response.TaskID)
status, ready, err := pollWikiMoveTask(ctx, client, runtime, response.TaskID)
if err != nil {
return nil, err
}
out["task_id"] = response.TaskID
out["ready"] = ready
out["failed"] = status.Failed()
out["status"] = status.PrimaryStatusCode()
out["status_msg"] = status.PrimaryStatusLabel()
if first := status.FirstResult(); first != nil {
appendWikiNodeOutput(out, first.Node)
if first.Node != nil && first.Node.NodeToken != "" {
out["wiki_token"] = first.Node.NodeToken
}
}
if !ready {
nextCommand := wikiMoveTaskResultCommand(response.TaskID, runtime.As())
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move task is still in progress. Continue with: %s\n", nextCommand)
out["timed_out"] = true
out["next_command"] = nextCommand
}
return out, nil
default:
return nil, output.Errorf(output.ExitAPI, "api_error", "move_docs_to_wiki returned neither wiki_token, task_id, nor applied result")
}
}
func wikiMoveTaskResultCommand(taskID string, identity core.Identity) string {
asFlag := string(identity)
if asFlag == "" {
asFlag = "user"
}
return fmt.Sprintf("lark-cli drive +task_result --scenario wiki_move --task-id %s --as %s", taskID, asFlag)
}
func pollWikiMoveTask(ctx context.Context, client wikiMoveClient, runtime *common.RuntimeContext, taskID string) (wikiMoveTaskStatus, bool, error) {
lastStatus := wikiMoveTaskStatus{TaskID: taskID}
var lastErr error
hadSuccessfulPoll := false
// The move request itself already succeeded. Treat poll failures as transient
// until every attempt fails, then return a resume hint instead of discarding
// the task identifier.
for attempt := 1; attempt <= wikiMovePollAttempts; attempt++ {
if attempt > 1 {
select {
case <-ctx.Done():
return lastStatus, false, ctx.Err()
case <-time.After(wikiMovePollInterval):
}
}
status, err := client.GetMoveTask(ctx, taskID)
if err != nil {
lastErr = err
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status attempt %d/%d failed: %v\n", attempt, wikiMovePollAttempts, err)
continue
}
lastStatus = status
hadSuccessfulPoll = true
if status.Ready() {
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move task completed successfully.\n")
return status, true, nil
}
if status.Failed() {
return status, false, output.Errorf(output.ExitAPI, "api_error", "wiki move task failed: %s", status.PrimaryStatusLabel())
}
fmt.Fprintf(runtime.IO().ErrOut, "Wiki move status %d/%d: %s\n", attempt, wikiMovePollAttempts, status.PrimaryStatusLabel())
}
if !hadSuccessfulPoll && lastErr != nil {
nextCommand := wikiMoveTaskResultCommand(taskID, runtime.As())
hint := fmt.Sprintf(
"the wiki move task was created but every status poll failed (task_id=%s)\nretry status lookup with: %s",
taskID,
nextCommand,
)
var exitErr *output.ExitError
if errors.As(lastErr, &exitErr) && exitErr.Detail != nil {
if strings.TrimSpace(exitErr.Detail.Hint) != "" {
hint = exitErr.Detail.Hint + "\n" + hint
}
return lastStatus, false, output.ErrWithHint(exitErr.Code, exitErr.Detail.Type, exitErr.Detail.Message, hint)
}
return lastStatus, false, output.ErrWithHint(output.ExitAPI, "api_error", lastErr.Error(), hint)
}
return lastStatus, false, nil
}
func parseWikiMoveTaskStatus(taskID string, task map[string]interface{}) (wikiMoveTaskStatus, error) {
if task == nil {
return wikiMoveTaskStatus{}, output.Errorf(output.ExitAPI, "api_error", "wiki task response missing task")
}
status := wikiMoveTaskStatus{
TaskID: common.GetString(task, "task_id"),
}
if status.TaskID == "" {
status.TaskID = taskID
}
for _, item := range common.GetSlice(task, "move_result") {
resultMap, ok := item.(map[string]interface{})
if !ok {
continue
}
var node *wikiNodeRecord
if nodeMap := common.GetMap(resultMap, "node"); nodeMap != nil {
parsedNode, err := parseWikiNodeRecord(nodeMap)
if err != nil {
return wikiMoveTaskStatus{}, err
}
node = parsedNode
}
status.MoveResults = append(status.MoveResults, wikiMoveTaskResult{
Node: node,
Status: int(common.GetFloat(resultMap, "status")),
StatusMsg: common.GetString(resultMap, "status_msg"),
})
}
return status, nil
}
func appendWikiNodeOutput(out map[string]interface{}, node *wikiNodeRecord) {
if out == nil || node == nil {
return
}
out["space_id"] = node.SpaceID
out["node_token"] = node.NodeToken
out["obj_token"] = node.ObjToken
out["obj_type"] = node.ObjType
out["parent_node_token"] = node.ParentNodeToken
out["node_type"] = node.NodeType
out["origin_node_token"] = node.OriginNodeToken
out["title"] = node.Title
out["has_child"] = node.HasChild
}

View File

@@ -0,0 +1,905 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package wiki
import (
"bytes"
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"sync"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/credential"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
type fakeWikiMoveNodeCall struct {
SourceSpaceID string
Spec wikiMoveSpec
}
type fakeWikiDocsToWikiMoveCall struct {
TargetSpaceID string
Spec wikiMoveSpec
}
type fakeWikiMoveClient struct {
nodes map[string]*wikiNodeRecord
getNodeErr error
moveNode *wikiNodeRecord
moveNodeErr error
docsResp *wikiMoveDocsResponse
docsErr error
taskStatuses []wikiMoveTaskStatus
taskErrs []error
getNodeCalls []string
moveNodeCalls []fakeWikiMoveNodeCall
docsToWikiCalls []fakeWikiDocsToWikiMoveCall
moveTaskCallArgs []string
}
func (fake *fakeWikiMoveClient) GetNode(ctx context.Context, token string) (*wikiNodeRecord, error) {
fake.getNodeCalls = append(fake.getNodeCalls, token)
if fake.getNodeErr != nil {
return nil, fake.getNodeErr
}
if node, ok := fake.nodes[token]; ok {
return node, nil
}
return &wikiNodeRecord{}, nil
}
func (fake *fakeWikiMoveClient) MoveNode(ctx context.Context, sourceSpaceID string, spec wikiMoveSpec) (*wikiNodeRecord, error) {
fake.moveNodeCalls = append(fake.moveNodeCalls, fakeWikiMoveNodeCall{SourceSpaceID: sourceSpaceID, Spec: spec})
if fake.moveNodeErr != nil {
return nil, fake.moveNodeErr
}
if fake.moveNode != nil {
return fake.moveNode, nil
}
return &wikiNodeRecord{SpaceID: sourceSpaceID, NodeToken: spec.NodeToken}, nil
}
func (fake *fakeWikiMoveClient) MoveDocsToWiki(ctx context.Context, targetSpaceID string, spec wikiMoveSpec) (*wikiMoveDocsResponse, error) {
fake.docsToWikiCalls = append(fake.docsToWikiCalls, fakeWikiDocsToWikiMoveCall{TargetSpaceID: targetSpaceID, Spec: spec})
if fake.docsErr != nil {
return nil, fake.docsErr
}
if fake.docsResp != nil {
return fake.docsResp, nil
}
return &wikiMoveDocsResponse{}, nil
}
func (fake *fakeWikiMoveClient) GetMoveTask(ctx context.Context, taskID string) (wikiMoveTaskStatus, error) {
idx := len(fake.moveTaskCallArgs)
fake.moveTaskCallArgs = append(fake.moveTaskCallArgs, taskID)
if idx < len(fake.taskErrs) && fake.taskErrs[idx] != nil {
return wikiMoveTaskStatus{TaskID: taskID}, fake.taskErrs[idx]
}
if idx < len(fake.taskStatuses) {
status := fake.taskStatuses[idx]
if status.TaskID == "" {
status.TaskID = taskID
}
return status, nil
}
return wikiMoveTaskStatus{TaskID: taskID}, nil
}
type mockWikiMoveTokenResolver struct {
token string
scopes string
err error
}
type wikiMoveAccountResolver struct {
cfg *core.CliConfig
}
func (r *wikiMoveAccountResolver) ResolveAccount(ctx context.Context) (*credential.Account, error) {
return credential.AccountFromCliConfig(r.cfg), nil
}
func (m *mockWikiMoveTokenResolver) ResolveToken(ctx context.Context, req credential.TokenSpec) (*credential.TokenResult, error) {
if m.err != nil {
return nil, m.err
}
token := m.token
if token == "" {
token = "test-token"
}
return &credential.TokenResult{Token: token, Scopes: m.scopes}, nil
}
var wikiMovePollMu sync.Mutex
func withSingleWikiMovePoll(t *testing.T) {
t.Helper()
wikiMovePollMu.Lock()
prevAttempts, prevInterval := wikiMovePollAttempts, wikiMovePollInterval
wikiMovePollAttempts, wikiMovePollInterval = 1, 0
t.Cleanup(func() {
wikiMovePollAttempts, wikiMovePollInterval = prevAttempts, prevInterval
wikiMovePollMu.Unlock()
})
}
func newWikiMoveRuntimeWithScopes(t *testing.T, as core.Identity, scopes string) (*common.RuntimeContext, *bytes.Buffer) {
t.Helper()
cfg := wikiTestConfig()
factory, _, stderr, _ := cmdutil.TestFactory(t, cfg)
factory.Credential = credential.NewCredentialProvider(nil, nil, &mockWikiMoveTokenResolver{scopes: scopes}, nil)
runtime := common.TestNewRuntimeContextWithIdentity(&cobra.Command{Use: "wiki +move"}, cfg, as)
runtime.Factory = factory
return runtime, stderr
}
func decodeWikiEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var env struct {
OK bool `json:"ok"`
Data map[string]interface{} `json:"data"`
}
if err := json.Unmarshal(stdout.Bytes(), &env); err != nil {
t.Fatalf("unmarshal wiki envelope: %v\nstdout=%s", err, stdout.String())
}
if !env.OK {
t.Fatalf("expected ok=true envelope, got stdout=%s", stdout.String())
}
return env.Data
}
func decodeWikiCapturedJSONBody(t *testing.T, stub *httpmock.Stub) map[string]interface{} {
t.Helper()
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v\nraw=%s", err, string(stub.CapturedBody))
}
return body
}
func TestValidateWikiMoveSpecRejectsInvalidCombinations(t *testing.T) {
t.Parallel()
tests := []struct {
name string
spec wikiMoveSpec
wantErr string
}{
{
name: "node move rejects docs flags",
spec: wikiMoveSpec{NodeToken: "wik_node", ObjType: "sheet", TargetSpaceID: "space_dst"},
wantErr: "cannot be combined",
},
{
name: "node move requires target",
spec: wikiMoveSpec{NodeToken: "wik_node"},
wantErr: "cannot both be empty",
},
{
name: "source space requires node token",
spec: wikiMoveSpec{SourceSpaceID: "space_src", ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
wantErr: "can only be used with --node-token",
},
{
name: "docs to wiki requires obj type",
spec: wikiMoveSpec{ObjToken: "sheet_token", TargetSpaceID: "space_dst"},
wantErr: "--obj-type is required",
},
{
name: "docs to wiki requires obj token",
spec: wikiMoveSpec{ObjType: "sheet", TargetSpaceID: "space_dst"},
wantErr: "--obj-token is required",
},
{
name: "docs to wiki requires target space",
spec: wikiMoveSpec{ObjType: "sheet", ObjToken: "sheet_token"},
wantErr: "--target-space-id is required",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := validateWikiMoveSpec(tt.spec)
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("expected error containing %q, got %v", tt.wantErr, err)
}
})
}
}
func TestValidateWikiMoveSpecAcceptsValidModes(t *testing.T) {
t.Parallel()
for _, spec := range []wikiMoveSpec{
{NodeToken: "wik_node", TargetSpaceID: "space_dst"},
{ObjType: "sheet", ObjToken: "sheet_token", TargetSpaceID: "space_dst", TargetParentToken: "wik_parent", Apply: true},
} {
if err := validateWikiMoveSpec(spec); err != nil {
t.Fatalf("validateWikiMoveSpec(%+v) error = %v", spec, err)
}
}
}
func TestWikiMoveDeclaredScopes(t *testing.T) {
t.Parallel()
want := []string{"wiki:node:move", "wiki:node:read", "wiki:space:read"}
if !reflect.DeepEqual(WikiMove.Scopes, want) {
t.Fatalf("WikiMove.Scopes = %v, want %v", WikiMove.Scopes, want)
}
}
func TestWikiMoveShortcutMissingDeclaredScope(t *testing.T) {
cfg := wikiTestConfig()
factory, stdout, _, _ := cmdutil.TestFactory(t, cfg)
factory.Credential = credential.NewCredentialProvider(nil, &wikiMoveAccountResolver{cfg: cfg}, &mockWikiMoveTokenResolver{scopes: "wiki:node:read"}, nil)
err := mountAndRunWiki(t, WikiMove, []string{
"+move",
"--node-token", "wik_node",
"--target-space-id", "space_dst",
"--as", "user",
}, factory, stdout)
if err == nil {
t.Fatal("expected missing scope error, got nil")
}
if !strings.Contains(err.Error(), "missing required scope(s): wiki:node:move") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWikiMoveTaskStatusPendingAndFallbackLabels(t *testing.T) {
t.Parallel()
pending := wikiMoveTaskStatus{}
if !pending.Pending() || pending.PrimaryStatusLabel() != "processing" {
t.Fatalf("pending status = %+v", pending)
}
ready := wikiMoveTaskStatus{MoveResults: []wikiMoveTaskResult{{Status: 0}}}
if !ready.Ready() || ready.PrimaryStatusLabel() != "success" {
t.Fatalf("ready status = %+v", ready)
}
failed := wikiMoveTaskStatus{MoveResults: []wikiMoveTaskResult{{Status: -1}}}
if !failed.Failed() || failed.PrimaryStatusLabel() != "failure" {
t.Fatalf("failed status = %+v", failed)
}
}
func TestWikiMoveTaskStatusPrimarySurfacesFailureOverEarlierSuccess(t *testing.T) {
t.Parallel()
status := wikiMoveTaskStatus{
MoveResults: []wikiMoveTaskResult{
{Status: 0, StatusMsg: "success"},
{Status: -3, StatusMsg: "permission denied"},
{Status: 1, StatusMsg: "processing"},
},
}
if got := status.PrimaryStatusCode(); got != -3 {
t.Fatalf("PrimaryStatusCode = %d, want -3", got)
}
if got := status.PrimaryStatusLabel(); got != "permission denied" {
t.Fatalf("PrimaryStatusLabel = %q, want permission denied", got)
}
// FirstResult must keep its literal "first entry" semantics for callers
// that flatten node fields from the first move_result.
if first := status.FirstResult(); first == nil || first.StatusMsg != "success" {
t.Fatalf("FirstResult = %+v, want first success entry", first)
}
}
func TestWikiMoveTaskStatusPrimaryPrefersProcessingOverFirstSuccess(t *testing.T) {
t.Parallel()
status := wikiMoveTaskStatus{
MoveResults: []wikiMoveTaskResult{
{Status: 0, StatusMsg: "success"},
{Status: 1, StatusMsg: "processing"},
},
}
if got := status.PrimaryStatusCode(); got != 1 {
t.Fatalf("PrimaryStatusCode = %d, want 1", got)
}
if got := status.PrimaryStatusLabel(); got != "processing" {
t.Fatalf("PrimaryStatusLabel = %q, want processing", got)
}
}
func TestWikiMoveValidateRejectsBotMyLibrary(t *testing.T) {
cfg := wikiTestConfig()
factory, stdout, _, _ := cmdutil.TestFactory(t, cfg)
err := mountAndRunWiki(t, WikiMove, []string{
"+move",
"--obj-type", "docx",
"--obj-token", "doccnXXX",
"--target-space-id", "my_library",
"--as", "bot",
}, factory, stdout)
if err == nil {
t.Fatal("expected validation error for bot + my_library, got nil")
}
if !strings.Contains(err.Error(), "my_library") || !strings.Contains(err.Error(), "--as bot") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestWikiMoveValidateAllowsUserMyLibrary(t *testing.T) {
t.Parallel()
// Bot guard must not affect user identity. We only assert the my_library
// validation path doesn't trip; an empty obj-token still fails downstream
// for unrelated reasons, so we check the error does not mention my_library.
if err := validateWikiMoveSpec(wikiMoveSpec{
ObjType: "docx",
ObjToken: "doccnXXX",
TargetSpaceID: "my_library",
}); err != nil {
t.Fatalf("validateWikiMoveSpec(user my_library) = %v, want nil", err)
}
}
func TestWikiMoveDryRunNodeMoveIncludesResolutionSteps(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "wiki +move"}
cmd.Flags().String("node-token", "", "")
cmd.Flags().String("source-space-id", "", "")
cmd.Flags().String("target-space-id", "", "")
cmd.Flags().String("target-parent-token", "", "")
cmd.Flags().String("obj-type", "", "")
cmd.Flags().String("obj-token", "", "")
cmd.Flags().Bool("apply", false, "")
if err := cmd.Flags().Set("node-token", "wik_node"); err != nil {
t.Fatalf("set --node-token: %v", err)
}
if err := cmd.Flags().Set("target-parent-token", "wik_parent"); err != nil {
t.Fatalf("set --target-parent-token: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := WikiMove.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)
}
if !bytes.Contains(data, []byte(`"description":"3-step orchestration:`)) {
t.Fatalf("dry run missing 3-step description: %s", string(data))
}
if !bytes.Contains(data, []byte(`"target_parent_token":"wik_parent"`)) {
t.Fatalf("dry run missing target_parent_token body: %s", string(data))
}
if !bytes.Contains(data, []byte(`/open-apis/wiki/v2/spaces/\u003cresolved_source_space_id\u003e/nodes/wik_node/move`)) {
t.Fatalf("dry run missing resolved source placeholder: %s", string(data))
}
}
func TestWikiMoveDryRunDocsToWikiIncludesTaskPoll(t *testing.T) {
t.Parallel()
cmd := &cobra.Command{Use: "wiki +move"}
cmd.Flags().String("node-token", "", "")
cmd.Flags().String("source-space-id", "", "")
cmd.Flags().String("target-space-id", "", "")
cmd.Flags().String("target-parent-token", "", "")
cmd.Flags().String("obj-type", "", "")
cmd.Flags().String("obj-token", "", "")
cmd.Flags().Bool("apply", false, "")
if err := cmd.Flags().Set("obj-type", "sheet"); err != nil {
t.Fatalf("set --obj-type: %v", err)
}
if err := cmd.Flags().Set("obj-token", "sheet_token"); err != nil {
t.Fatalf("set --obj-token: %v", err)
}
if err := cmd.Flags().Set("target-space-id", "space_dst"); err != nil {
t.Fatalf("set --target-space-id: %v", err)
}
if err := cmd.Flags().Set("apply", "true"); err != nil {
t.Fatalf("set --apply: %v", err)
}
runtime := common.TestNewRuntimeContext(cmd, nil)
dry := WikiMove.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)
}
if !bytes.Contains(data, []byte(`"obj_type":"sheet"`)) || !bytes.Contains(data, []byte(`"apply":true`)) {
t.Fatalf("dry run missing docs-to-wiki body: %s", string(data))
}
if !bytes.Contains(data, []byte(`"task_type":"move"`)) {
t.Fatalf("dry run missing task polling params: %s", string(data))
}
}
func TestResolveWikiNodeMoveSpacesUsesSourceAndTargetLookups(t *testing.T) {
t.Parallel()
client := &fakeWikiMoveClient{
nodes: map[string]*wikiNodeRecord{
"wik_node": {SpaceID: "space_src"},
"wik_parent": {SpaceID: "space_dst"},
},
}
sourceSpaceID, targetSpaceID, err := resolveWikiNodeMoveSpaces(context.Background(), client, wikiMoveSpec{
NodeToken: "wik_node",
TargetParentToken: "wik_parent",
})
if err != nil {
t.Fatalf("resolveWikiNodeMoveSpaces() error = %v", err)
}
if sourceSpaceID != "space_src" || targetSpaceID != "space_dst" {
t.Fatalf("resolved spaces = (%q, %q), want (%q, %q)", sourceSpaceID, targetSpaceID, "space_src", "space_dst")
}
if strings.Join(client.getNodeCalls, ",") != "wik_node,wik_parent" {
t.Fatalf("getNodeCalls = %v, want source and target-parent lookups", client.getNodeCalls)
}
}
func TestResolveWikiNodeMoveSpacesRejectsTargetSpaceMismatch(t *testing.T) {
t.Parallel()
client := &fakeWikiMoveClient{
nodes: map[string]*wikiNodeRecord{
"wik_parent": {SpaceID: "space_parent"},
},
}
_, _, err := resolveWikiNodeMoveSpaces(context.Background(), client, wikiMoveSpec{
NodeToken: "wik_node",
SourceSpaceID: "space_src",
TargetSpaceID: "space_other",
TargetParentToken: "wik_parent",
})
if err == nil || !strings.Contains(err.Error(), "does not match") {
t.Fatalf("expected mismatch error, got %v", err)
}
}
func TestRunWikiNodeMoveReturnsResolvedMetadata(t *testing.T) {
t.Parallel()
client := &fakeWikiMoveClient{
nodes: map[string]*wikiNodeRecord{
"wik_node": {SpaceID: "space_src"},
"wik_parent": {SpaceID: "space_dst"},
},
moveNode: &wikiNodeRecord{
SpaceID: "space_dst",
NodeToken: "wik_moved",
ObjToken: "sheet_token",
ObjType: "sheet",
ParentNodeToken: "wik_parent",
NodeType: wikiNodeTypeOrigin,
Title: "Roadmap",
},
}
out, err := runWikiNodeMove(context.Background(), client, wikiMoveSpec{
NodeToken: "wik_node",
TargetParentToken: "wik_parent",
})
if err != nil {
t.Fatalf("runWikiNodeMove() error = %v", err)
}
if len(client.moveNodeCalls) != 1 {
t.Fatalf("MoveNode called %d times, want 1", len(client.moveNodeCalls))
}
if client.moveNodeCalls[0].SourceSpaceID != "space_src" {
t.Fatalf("source space = %q, want %q", client.moveNodeCalls[0].SourceSpaceID, "space_src")
}
if out["mode"] != wikiMoveModeNode || out["source_space_id"] != "space_src" || out["target_space_id"] != "space_dst" {
t.Fatalf("unexpected node move output: %#v", out)
}
if out["node_token"] != "wik_moved" || out["title"] != "Roadmap" {
t.Fatalf("node fields not propagated: %#v", out)
}
}
func TestRunWikiMoveDispatchesByMode(t *testing.T) {
t.Parallel()
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
docsResp: &wikiMoveDocsResponse{WikiToken: "wik_ready"},
moveNode: &wikiNodeRecord{SpaceID: "space_dst", NodeToken: "wik_node"},
}
nodeOut, err := runWikiMove(context.Background(), client, runtime, wikiMoveSpec{
NodeToken: "wik_node",
SourceSpaceID: "space_src",
TargetSpaceID: "space_dst",
})
if err != nil {
t.Fatalf("runWikiMove(node) error = %v", err)
}
if nodeOut["mode"] != wikiMoveModeNode {
t.Fatalf("node mode output = %#v", nodeOut)
}
docsOut, err := runWikiMove(context.Background(), client, runtime, wikiMoveSpec{
ObjType: "sheet",
ObjToken: "sheet_token",
TargetSpaceID: "space_dst",
})
if err != nil {
t.Fatalf("runWikiMove(docs_to_wiki) error = %v", err)
}
if docsOut["mode"] != wikiMoveModeDocsToWiki {
t.Fatalf("docs-to-wiki output = %#v", docsOut)
}
}
func TestRunWikiDocsToWikiMoveSyncReady(t *testing.T) {
t.Parallel()
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
docsResp: &wikiMoveDocsResponse{WikiToken: "wik_ready"},
}
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
ObjType: "sheet",
ObjToken: "sheet_token",
TargetSpaceID: "space_dst",
})
if err != nil {
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
}
if out["ready"] != true || out["failed"] != false {
t.Fatalf("expected ready sync result, got %#v", out)
}
if out["wiki_token"] != "wik_ready" || out["node_token"] != "wik_ready" {
t.Fatalf("wiki token fields = %#v", out)
}
if len(client.docsToWikiCalls) != 1 || client.docsToWikiCalls[0].TargetSpaceID != "space_dst" {
t.Fatalf("unexpected docs-to-wiki calls: %#v", client.docsToWikiCalls)
}
}
func TestRunWikiDocsToWikiMoveApplied(t *testing.T) {
t.Parallel()
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
docsResp: &wikiMoveDocsResponse{Applied: true},
}
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
ObjType: "sheet",
ObjToken: "sheet_token",
TargetSpaceID: "space_dst",
})
if err != nil {
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
}
if out["applied"] != true || out["ready"] != false || out["failed"] != false {
t.Fatalf("expected applied response, got %#v", out)
}
if out["status_msg"] != "move request submitted for approval" {
t.Fatalf("status_msg = %#v", out["status_msg"])
}
}
func TestRunWikiDocsToWikiMoveAsyncReady(t *testing.T) {
withSingleWikiMovePoll(t)
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
docsResp: &wikiMoveDocsResponse{TaskID: "task_123"},
taskStatuses: []wikiMoveTaskStatus{{
MoveResults: []wikiMoveTaskResult{{
Status: 0,
StatusMsg: "success",
Node: &wikiNodeRecord{
SpaceID: "space_dst",
NodeToken: "wik_done",
ObjToken: "sheet_token",
ObjType: "sheet",
NodeType: wikiNodeTypeOrigin,
Title: "Roadmap",
},
}},
}},
}
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
ObjType: "sheet",
ObjToken: "sheet_token",
TargetSpaceID: "space_dst",
})
if err != nil {
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
}
if out["task_id"] != "task_123" || out["ready"] != true || out["failed"] != false {
t.Fatalf("unexpected async-ready output: %#v", out)
}
if out["wiki_token"] != "wik_done" || out["title"] != "Roadmap" || out["status_msg"] != "success" {
t.Fatalf("async-ready output missing flattened fields: %#v", out)
}
if !strings.Contains(stderr.String(), "Docs-to-wiki move is async") || !strings.Contains(stderr.String(), "completed successfully") {
t.Fatalf("stderr = %q, want async progress logs", stderr.String())
}
}
func TestRunWikiDocsToWikiMoveAsyncTimeoutReturnsNextCommand(t *testing.T) {
withSingleWikiMovePoll(t)
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
docsResp: &wikiMoveDocsResponse{TaskID: "task_123"},
taskStatuses: []wikiMoveTaskStatus{{
MoveResults: []wikiMoveTaskResult{{Status: 1, StatusMsg: "processing"}},
}},
}
out, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
ObjType: "sheet",
ObjToken: "sheet_token",
TargetSpaceID: "space_dst",
})
if err != nil {
t.Fatalf("runWikiDocsToWikiMove() error = %v", err)
}
if out["ready"] != false || out["timed_out"] != true || out["next_command"] != wikiMoveTaskResultCommand("task_123", core.AsUser) {
t.Fatalf("expected timeout response, got %#v", out)
}
if out["status_msg"] != "processing" {
t.Fatalf("status_msg = %#v, want processing", out["status_msg"])
}
if !strings.Contains(stderr.String(), "Continue with") {
t.Fatalf("stderr = %q, want continuation hint", stderr.String())
}
}
func TestRunWikiDocsToWikiMoveAsyncFailureReturnsStructuredError(t *testing.T) {
withSingleWikiMovePoll(t)
runtime, _ := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
docsResp: &wikiMoveDocsResponse{TaskID: "task_123"},
taskStatuses: []wikiMoveTaskStatus{{
MoveResults: []wikiMoveTaskResult{{Status: -1, StatusMsg: "approval rejected"}},
}},
}
_, err := runWikiDocsToWikiMove(context.Background(), client, runtime, wikiMoveSpec{
ObjType: "sheet",
ObjToken: "sheet_token",
TargetSpaceID: "space_dst",
})
if err == nil || !strings.Contains(err.Error(), "wiki move task failed: approval rejected") {
t.Fatalf("expected async failure error, got %v", err)
}
}
func TestWikiMoveExecuteNodeShortcut(t *testing.T) {
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{"space_id": "space_src"},
},
},
})
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/spaces/get_node",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{"space_id": "space_dst"},
},
},
})
moveStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_src/nodes/wik_node/move",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_moved",
"obj_token": "sheet_token",
"obj_type": "sheet",
"parent_node_token": "wik_parent",
"node_type": "origin",
"title": "Roadmap",
},
},
},
}
reg.Register(moveStub)
err := mountAndRunWiki(t, WikiMove, []string{
"+move",
"--node-token", "wik_node",
"--target-parent-token", "wik_parent",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["mode"] != wikiMoveModeNode || data["source_space_id"] != "space_src" || data["target_space_id"] != "space_dst" {
t.Fatalf("unexpected node shortcut output: %#v", data)
}
body := decodeWikiCapturedJSONBody(t, moveStub)
if body["target_parent_token"] != "wik_parent" {
t.Fatalf("move body = %#v, want target_parent_token", body)
}
}
func TestWikiMoveExecuteDocsToWikiShortcutAsyncSuccess(t *testing.T) {
withSingleWikiMovePoll(t)
factory, stdout, _, reg := cmdutil.TestFactory(t, wikiTestConfig())
docsStub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/wiki/v2/spaces/space_dst/nodes/move_docs_to_wiki",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task_id": "task_123",
},
},
}
reg.Register(docsStub)
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/wiki/v2/tasks/task_123",
Body: map[string]interface{}{
"code": 0,
"data": map[string]interface{}{
"task": map[string]interface{}{
"task_id": "task_123",
"move_result": []interface{}{
map[string]interface{}{
"status": 0,
"status_msg": "success",
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_done",
"obj_token": "sheet_token",
"obj_type": "sheet",
"node_type": "origin",
"title": "Roadmap",
},
},
},
},
},
},
})
err := mountAndRunWiki(t, WikiMove, []string{
"+move",
"--obj-type", "sheet",
"--obj-token", "sheet_token",
"--target-space-id", "space_dst",
"--apply",
"--as", "user",
}, factory, stdout)
if err != nil {
t.Fatalf("mountAndRunWiki() error = %v", err)
}
data := decodeWikiEnvelope(t, stdout)
if data["mode"] != wikiMoveModeDocsToWiki || data["ready"] != true || data["wiki_token"] != "wik_done" {
t.Fatalf("unexpected docs-to-wiki shortcut output: %#v", data)
}
body := decodeWikiCapturedJSONBody(t, docsStub)
if body["obj_type"] != "sheet" || body["obj_token"] != "sheet_token" || body["apply"] != true {
t.Fatalf("docs-to-wiki body = %#v", body)
}
}
func TestPollWikiMoveTaskWrapsRepeatedPollFailuresWithHint(t *testing.T) {
withSingleWikiMovePoll(t)
runtime, stderr := newWikiMoveRuntimeWithScopes(t, core.AsUser, "")
client := &fakeWikiMoveClient{
taskErrs: []error{output.ErrWithHint(output.ExitAPI, "api_error", "poll failed", "retry original")},
}
status, ready, err := pollWikiMoveTask(context.Background(), client, runtime, "task_123")
if err == nil {
t.Fatal("expected pollWikiMoveTask() error, got nil")
}
if ready {
t.Fatal("expected ready=false when every poll fails")
}
if status.TaskID != "task_123" {
t.Fatalf("status.TaskID = %q, want %q", status.TaskID, "task_123")
}
var exitErr *output.ExitError
if !errors.As(err, &exitErr) || exitErr.Detail == nil {
t.Fatalf("expected structured exit error, got %T %v", err, err)
}
if !strings.Contains(exitErr.Detail.Hint, "retry original") || !strings.Contains(exitErr.Detail.Hint, wikiMoveTaskResultCommand("task_123", core.AsUser)) {
t.Fatalf("hint = %q, want original hint and resume command", exitErr.Detail.Hint)
}
if !strings.Contains(stderr.String(), "Wiki move status attempt 1/1 failed") {
t.Fatalf("stderr = %q, want poll failure log", stderr.String())
}
}
func TestParseWikiMoveTaskStatusFallbackTaskIDAndNode(t *testing.T) {
t.Parallel()
status, err := parseWikiMoveTaskStatus("task_fallback", map[string]interface{}{
"move_result": []interface{}{
map[string]interface{}{
"status": 0,
"status_msg": "success",
"node": map[string]interface{}{
"space_id": "space_dst",
"node_token": "wik_done",
"obj_token": "sheet_token",
"obj_type": "sheet",
"node_type": wikiNodeTypeOrigin,
"title": "Roadmap",
},
},
},
})
if err != nil {
t.Fatalf("parseWikiMoveTaskStatus() error = %v", err)
}
if status.TaskID != "task_fallback" {
t.Fatalf("TaskID = %q, want %q", status.TaskID, "task_fallback")
}
if !status.Ready() || status.PrimaryStatusLabel() != "success" {
t.Fatalf("unexpected parsed status: %+v", status)
}
if first := status.FirstResult(); first == nil || first.Node == nil || first.Node.NodeToken != "wik_done" {
t.Fatalf("parsed node = %+v", first)
}
}
func TestParseWikiMoveTaskStatusRejectsMissingTask(t *testing.T) {
t.Parallel()
_, err := parseWikiMoveTaskStatus("task_123", nil)
if err == nil || !strings.Contains(err.Error(), "missing task") {
t.Fatalf("expected missing task error, got %v", err)
}
}

View File

@@ -94,15 +94,18 @@ func mountAndRunWiki(t *testing.T, shortcut common.Shortcut, args []string, fact
return parent.Execute()
}
func TestWikiShortcutsIncludesNodeCreate(t *testing.T) {
func TestWikiShortcutsIncludeMoveAndNodeCreate(t *testing.T) {
t.Parallel()
shortcuts := Shortcuts()
if len(shortcuts) != 1 {
t.Fatalf("len(Shortcuts()) = %d, want 1", len(shortcuts))
if len(shortcuts) != 2 {
t.Fatalf("len(Shortcuts()) = %d, want 2", len(shortcuts))
}
if shortcuts[0].Command != "+node-create" {
t.Fatalf("shortcut command = %q, want %q", shortcuts[0].Command, "+node-create")
if shortcuts[0].Command != "+move" {
t.Fatalf("shortcuts[0].Command = %q, want %q", shortcuts[0].Command, "+move")
}
if shortcuts[1].Command != "+node-create" {
t.Fatalf("shortcuts[1].Command = %q, want %q", shortcuts[1].Command, "+node-create")
}
}

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