Compare commits

..

30 Commits

Author SHA1 Message Date
bubbmon233
f495cbb166 feat(mail): add message modify and trash shortcuts (#1567) 2026-07-07 21:44:40 +08:00
Yuxuan Zhao
6f95c5eb22 e2e: harden CLI E2E retry, cleanup, and domain selection (#1709) 2026-07-07 19:41:11 +08:00
liangshuo-1
4e2cbea94e chore: release v1.0.66 (#1782) 2026-07-07 19:33:39 +08:00
fangshuyu-768
f98dbfe247 Improve agent-facing error guidance for drive, markdown, and wiki (#1779) 2026-07-07 18:10:23 +08:00
hugang-lark
40ea4d60ef feat: minute wait (#1768)
feat: remove reference lark-sharded skill

docs: clarify calendar write feedback

docs: split calendar scheduling workflow

docs: tighten calendar skill references

docs: annotate +freebusy scope to avoid unnecessary reads in scheduling flow

When users express "find free time + create event" intent, AI would
previously read freebusy.md before entering the scheduling workflow.
Adding a scope note to the shortcut table directs AI to use +suggestion
instead, reducing token consumption.

feat: support calendar +get

fix: optimize calendar skill

fix: optimize shortcuts and meta api

fix: optimize skill

Co-authored-by: calendar-assistant <tangfengyuan@bytedance.com>
2026-07-07 17:12:28 +08:00
liangshuo-1
f0b6f35fee fix: resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
* fix(schema): fall back to runtime catalog when no embedded metadata

Binaries built from the bare Go module (plugin builds) embed only the
empty meta_data_default.json stub because meta_data.json is gitignored
and fetched at build time. The schema command, its completion, and the
affordance command-form resolver read the embedded-only catalog, so
every schema lookup failed with "Unknown service" even though the
runtime registry had already sync-fetched full metadata.

Add registry.SchemaCatalog(): embedded when compiled in (official
builds unchanged, still deterministic), otherwise the merged runtime
catalog seeded from cache or remote fetch. When neither source has
data (offline plugin build with a cold cache), schema now returns a
failed_precondition error with an actionable hint instead of
"Unknown service" with an empty candidate list.

* fix(registry): gate cached meta overlay on version newer than embedded

The cached remote meta was overlaid onto the embedded meta_data.json
unconditionally, so after a CLI upgrade an equal- or older-version
cache kept shadowing the freshly shipped embedded definitions until a
later refresh happened to rewrite it.

Only overlay when the cache version is strictly newer than the
embedded baseline. The bare-module stub baseline is "0.0.0", so plugin
builds without compiled metadata still take any real cached version
(TestOverlayGate_StubEmbedded_OverlaysRealCache) and the schema
runtime fallback keeps working offline from a warm cache.

Ports #1376 onto the typed meta model.

---------

Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-07-06 21:24:24 +08:00
liangshuo-1
91d785f92f refactor(envvars): consolidate agent env value access (#1757)
* feat(envvars): add LARKSUITE_CLI_AGENT_NAME accessor

envvars.AgentName() reads LARKSUITE_CLI_AGENT_NAME so business code can identify the agent driving the CLI. Value is sanitised (trim, reject control chars, 128-byte cap) and returns "" when unset or invalid. Local-only; not sent to the server.

* refactor(envvars): consolidate agent env accessors, dedupe sanitize

Move agent-trace reading from cmdutil.AgentTraceValue into envvars.AgentTrace(), symmetric with AgentName and sharing one sanitizeSingleLine. secheader.go now only assembles headers (BaseSecurityHeaders calls envvars.AgentTrace()); the duplicated sanitize and its os/unicode imports are gone. The X-Agent-Trace header and its behaviour are unchanged.

---------

Co-authored-by: liangshuo-1 <266696938+liangshuo-1@users.noreply.github.com>
2026-07-06 21:17:51 +08:00
leave330
e621c6e50f fix: support pnpm global installs in self-update (#1705) 2026-07-06 21:11:01 +08:00
Zee Zheng
869a259d4e docs: clarify success envelope contract — judge success by ok, not code (#1730)
The stdout success envelope is {ok, identity, data, meta?} and carries no
top-level code/msg field, but wrappers following the raw OpenAPI convention
test code == 0 and misclassify every successful call as a failure. Around
write commands this defeats caller-side idempotency and causes duplicate
creates (see #1689).

Document the contract in ERROR_CONTRACT.md (success-envelope counterpart to
the error wire format), both READMEs (JSON Output Contract section), the
lark-shared skill (shared prerequisite for all skills), and add a success
response example to lark-task-create.md.

Closes #1689
2026-07-06 20:03:05 +08:00
fangshuyu-768
ee46e22abd docs: tighten doc creation validation workflow (#1759) 2026-07-06 17:07:10 +08:00
hhang
b76dc18c2f fix(calendar): guide approval room booking fallback (#1637) 2026-07-06 15:04:50 +08:00
calendar-assistant
85679d4258 feat: support semantic recurring calendar operations (#1723) 2026-07-06 14:10:57 +08:00
wangweiming-01
1ba4f3973c fix: guide drive import concurrency conflicts (#1751) 2026-07-06 10:46:46 +08:00
liangshuo-1
c45ff569c4 chore: release v1.0.65 (#1742) 2026-07-03 20:25:22 +08:00
caojie0621
a1506cdffb feat: add docs history shortcuts (#1612)
Add docs +history-list, +history-revert, and +history-revert-status backed by docs_ai history OpenAPI endpoints.

Document the safe history workflow and extend dry-run/live E2E coverage for the new shortcuts.
2026-07-03 16:21:18 +08:00
liuxin-0319
3595356ea1 chore: sync lark-doc skill from online-doc (#1701) 2026-07-03 15:55:46 +08:00
zhangjun-bytedance
73be1d06ec bugfix 0702 about speaker replace (#1731) 2026-07-03 14:05:48 +08:00
liujinkun2025
cccf025599 docs(drive): document 30-char query limit for +search (#1560)
The Search v2 API rejects queries longer than 30 characters (counted by
Unicode code point, CJK 1 each) with 99992402 field validation failed —
it is a hard error, not truncation. Surface this in the --query -h help
text and the lark-drive search skill so callers compress long queries
before searching instead of hitting the error.

Change-Id: Ieb30a66edae7a573690c49719627ec8fb2500a1a
2026-07-03 11:39:07 +08:00
zhanglei-1103
7db899db01 docs: add mindnote guidance to lark-doc (#1581) 2026-07-03 10:50:25 +08:00
liangshuo-1
c2d6038aae chore: release v1.0.64 (#1725) 2026-07-02 21:58:37 +08:00
Lekko
efa3439e01 fix(update): increase npm registry fetch timeout from 5s to 15s (#1724)
The update checker fetches https://registry.npmjs.org/@larksuite/cli/latest
with a 5-second HTTP client timeout. Under high-latency network conditions
(TUN-mode proxies, VPNs, transcontinental routes), TLS handshake alone can
take 4-6 seconds, causing the check to fail with:

  context deadline exceeded (Client.Timeout exceeded while awaiting headers)

Measured example behind a Clash TUN proxy (US node from China):
  DNS resolve:    ~0ms (fake-ip)
  TCP connect:    ~0ms (local TUN)
  TLS handshake:  4.3-5.9s  <-- bottleneck
  Total:          4.7-6.3s

curl succeeds because it has no default connect timeout, but the Go HTTP
client with Timeout=5s is too tight. The registry endpoint returns a tiny
JSON payload (<1KB), so 15s is more than enough headroom while still
failing fast on genuinely unreachable networks.

Co-authored-by: 王伟达 <weida.wang@m.com>
2026-07-02 21:36:44 +08:00
91-enjoy
9f150670f3 feat: upgrade card send (#1688) 2026-07-02 19:46:11 +08:00
liangshuo-1
578e2db4e0 fix: point permission-apply link at official /page/scope-apply entry (#1722) 2026-07-02 19:26:00 +08:00
Paulazaaza-dev
94139751d3 Add detailed command-to-reference mapping for the approval skill. (#1630) 2026-07-02 19:08:19 +08:00
shifengjuan-dev
8c3ed5d224 feat(im): add +chat-members-list shortcut for member listing (#1398)
Add a dedicated +chat-members-list shortcut that lists chat members,
returning users and bots in separate users[] / bots[] buckets. It owns its
pagination loop (mirroring the paginateLoop conventions: per-page log line,
--page-limit cap, non-advancing-token guard) because the list_members
response is multi-bucket: the generic --page-all merger is built for
single-array responses and would silently drop the bots[] bucket and the
final-page truncations[] signal.

Highlights:
- merges users[] and bots[] across pages; takes truncations[] / has_more /
  page_token from the last page so a server-side cap is never hidden
- surfaces truncations[] with a loud stderr warning when the server caps a
  bucket due to security config (the list is incomplete)
- --member-types filter (user/bot), --member-id-type, and the standard
  --page-all / --page-limit / --page-token flags
- with --page-all and no explicit --page-size, uses the max page size to
  minimize round-trips
- docs: SKILL.md Shortcuts table + references/lark-im-chat-members-list.md
2026-07-02 18:09:51 +08:00
zhanghuanxu
c982df4cf0 test: inline slide lint XML fixtures 2026-07-02 18:02:04 +08:00
zhanghuanxu
fb5ae41bca docs: remove lark slides template toolchain 2026-07-02 18:02:04 +08:00
zhanghuanxu
87e872a4c1 docs: add lark slides generation constraints 2026-07-02 18:02:04 +08:00
syh-cpdsss
ddc0f2a521 feat(okr): semi-plain text format with mention position preservation + patch shortcut (#1671)
Add semi-plain text (simple) format for OKR content I/O, and a new `+patch`
shortcut for incremental updates to objectives and key results.
2026-07-02 17:45:00 +08:00
HanShaoshuai-k
440867f1b4 fix: reduce public content token false positives 2026-07-02 17:39:49 +08:00
279 changed files with 15612 additions and 77430 deletions

View File

@@ -263,13 +263,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with: with:
go-version-file: go.mod go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with: with:
python-version: '3.x' python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli - name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build run: make build
- name: Run dry-run E2E tests - name: Run dry-run E2E tests
env: env:
@@ -277,7 +283,28 @@ jobs:
LARKSUITE_CLI_APP_ID: dry-run LARKSUITE_CLI_APP_ID: dry-run
LARKSUITE_CLI_APP_SECRET: dry-run LARKSUITE_CLI_APP_SECRET: dry-run
LARKSUITE_CLI_BRAND: feishu LARKSUITE_CLI_BRAND: feishu
run: go test -v -count=1 -timeout=5m ./tests/cli_e2e/... -run 'DryRun|Regression' E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_DRY_ROOT_PACKAGE: ${{ steps.e2e_domains.outputs.dry_root_package }}
E2E_DRY_PACKAGES: ${{ steps.e2e_domains.outputs.dry_packages }}
run: |
if [ "$E2E_MODE" = "skip" ]; then
echo "No dry-run CLI E2E needed: $E2E_REASON"
exit 0
fi
if [ -z "$E2E_DRY_ROOT_PACKAGE" ] && [ -z "$E2E_DRY_PACKAGES" ]; then
echo "::error::No dry-run CLI E2E packages resolved for mode $E2E_MODE"
exit 1
fi
echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"
if [ -n "$E2E_DRY_ROOT_PACKAGE" ]; then
echo "Dry-run CLI E2E root package: $E2E_DRY_ROOT_PACKAGE"
go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"
fi
if [ -n "$E2E_DRY_PACKAGES" ]; then
echo "Dry-run CLI E2E packages: $E2E_DRY_PACKAGES"
go test -v -count=1 -timeout=5m $E2E_DRY_PACKAGES -run 'DryRun|Regression'
fi
e2e-live: e2e-live:
needs: [unit-test, lint, script-test, deterministic-gate] needs: [unit-test, lint, script-test, deterministic-gate]
@@ -292,15 +319,22 @@ jobs:
TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }} TEST_USER_ACCESS_TOKEN: ${{ secrets.TEST_USER_ACCESS_TOKEN }}
steps: steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5
with:
fetch-depth: 0
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with: with:
go-version-file: go.mod go-version-file: go.mod
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6
with: with:
python-version: '3.x' python-version: '3.x'
- name: Resolve CLI E2E domains
id: e2e_domains
run: node scripts/e2e_domains.js
- name: Build lark-cli - name: Build lark-cli
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: make build run: make build
- name: Configure bot credentials - name: Configure bot credentials
if: ${{ steps.e2e_domains.outputs.mode != 'skip' }}
run: | run: |
if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then if [ -z "$TEST_BOT1_APP_ID" ] || [ -z "$TEST_BOT1_APP_SECRET" ]; then
echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET" echo "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_APP_SECRET"
@@ -310,16 +344,24 @@ jobs:
- name: Run CLI E2E tests - name: Run CLI E2E tests
env: env:
LARK_CLI_BIN: ${{ github.workspace }}/lark-cli LARK_CLI_BIN: ${{ github.workspace }}/lark-cli
E2E_MODE: ${{ steps.e2e_domains.outputs.mode }}
E2E_REASON: ${{ steps.e2e_domains.outputs.reason }}
E2E_LIVE_PACKAGES: ${{ steps.e2e_domains.outputs.live_packages }}
run: | run: |
packages=$(go list ./tests/cli_e2e/... | grep -v '^github.com/larksuite/cli/tests/cli_e2e$' | grep -v '/demo$') if [ "$E2E_MODE" = "skip" ]; then
echo "No live CLI E2E needed: $E2E_REASON"
exit 0
fi
packages="$E2E_LIVE_PACKAGES"
if [ -z "$packages" ]; then if [ -z "$packages" ]; then
echo "No CLI E2E packages to test after exclusions." echo "::error::No live CLI E2E packages resolved for mode $E2E_MODE"
exit 1 exit 1
fi fi
packages_arg=$(printf '%s\n' "$packages" | paste -sd' ' -) echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"
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 echo "Live CLI E2E packages: $packages"
go run gotest.tools/gotestsum@v1.12.3 --rerun-fails=2 --rerun-fails-max-failures=20 --packages="$packages" --format testname --junitfile cli-e2e-report.xml -- -count=1 -v
- name: Publish CLI E2E test report - name: Publish CLI E2E test report
if: ${{ !cancelled() }} if: ${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}
uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0 uses: dorny/test-reporter@a43b3a5f7366b97d083190328d2c652e1a8b6aa2 # v3.0.0
with: with:
name: CLI E2E Tests name: CLI E2E Tests

3
.gitignore vendored
View File

@@ -27,6 +27,9 @@ Thumbs.db
# Go # Go
docs/ref docs/ref
docs/ docs/
!tests/cli_e2e/docs/
!tests/cli_e2e/docs/*.go
!tests/cli_e2e/docs/*.md
vendor/ vendor/

View File

@@ -2,6 +2,71 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [v1.0.66] - 2026-07-07
### Features
- support semantic recurring calendar operations (#1723)
- minute wait (#1768)
### Bug Fixes
- guide drive import concurrency conflicts (#1751)
- **calendar**: guide approval room booking fallback (#1637)
- support pnpm global installs in self-update (#1705)
- resolve schema against runtime metadata in plugin builds; gate cache overlay by version (#1764)
### Documentation
- tighten doc creation validation workflow (#1759)
- clarify success envelope contract — judge success by ok, not code (#1730)
### Refactoring
- **envvars**: consolidate agent env value access (#1757)
### Misc
- Improve agent-facing error guidance for drive, markdown, and wiki (#1779)
## [v1.0.65] - 2026-07-03
### Features
- **doc**: Add `+history-list`, `+history-revert`, and `+history-revert-status` shortcuts for document version history (#1612)
### Bug Fixes
- **minutes**: `+speaker-replace` no longer refetches the speaker list — `--from-speaker-id` is passed through as-is (#1731)
### Documentation
- **drive**: Document 30-char query limit for `+search` (#1560)
- **doc**: Add mindnote guidance to lark-doc skill (#1581)
- **doc**: Sync lark-doc skill content from online-doc (#1701)
## [v1.0.64] - 2026-07-02
### Features
- **im**: Upgrade card send to Card 2.0 with full component reference (#1688)
- **im**: Add `+chat-members-list` shortcut for member listing (#1398)
- **okr**: Semi-plain text format with mention position preservation and `patch` shortcut (#1671)
### Bug Fixes
- **cli**: Point permission-apply link at official `/page/scope-apply` entry (#1722)
- **cli**: Improve secure label error handling (#1707)
- **cli**: Reduce public content token false positives
- **cli**: Increase npm registry fetch timeout to 15s during update check (#1724)
- **doc**: Align word statistics compound tokens (#1706)
### Documentation
- **approval**: Add detailed command-to-reference mapping for the approval skill (#1630)
- **doc**: Support `reference_map` in docs (#1690)
- **slides**: Refresh generation guidance — add constraints, drop template toolchain, and inline lint XML fixtures
## [v1.0.62] - 2026-07-01 ## [v1.0.62] - 2026-07-01
### Features ### Features
@@ -1333,6 +1398,9 @@ Bundled AI agent skills for intelligent assistance:
- Bilingual documentation (English & Chinese). - Bilingual documentation (English & Chinese).
- CI/CD pipelines: linting, testing, coverage reporting, and automated releases. - CI/CD pipelines: linting, testing, coverage reporting, and automated releases.
[v1.0.66]: https://github.com/larksuite/cli/releases/tag/v1.0.66
[v1.0.65]: https://github.com/larksuite/cli/releases/tag/v1.0.65
[v1.0.64]: https://github.com/larksuite/cli/releases/tag/v1.0.64
[v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62 [v1.0.62]: https://github.com/larksuite/cli/releases/tag/v1.0.62
[v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61 [v1.0.61]: https://github.com/larksuite/cli/releases/tag/v1.0.61
[v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60 [v1.0.60]: https://github.com/larksuite/cli/releases/tag/v1.0.60

View File

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

View File

@@ -233,6 +233,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # Comma-separated values --format csv # Comma-separated values
``` ```
### JSON Output Contract
With `--format json` (the default), success and error envelopes are distinct.
Success goes to **stdout**, exit code `0`:
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
Errors go to **stderr**, non-zero exit code:
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
To check whether a command succeeded, test `ok == true` (or the exit code) — **not** `code == 0`. Unlike raw OpenAPI responses (`{"code": 0, "msg": "ok", ...}`), the success envelope carries no `code` or `msg` field; `code` appears only inside `error` as the upstream OpenAPI code. See [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md) for the full error taxonomy.
### Pagination ### Pagination
```bash ```bash

View File

@@ -234,6 +234,24 @@ lark-cli api POST /open-apis/im/v1/messages --params '{"receive_id_type":"chat_i
--format csv # 逗号分隔值 --format csv # 逗号分隔值
``` ```
### JSON 输出契约
`--format json`(默认)下,成功与错误的信封结构不同。
成功信封写入 **stdout**,退出码 0
```json
{ "ok": true, "identity": "user", "data": { "guid": "..." }, "meta": { "count": 1 } }
```
错误信封写入 **stderr**,退出码非 0
```json
{ "ok": false, "identity": "user", "error": { "type": "api", "subtype": "...", "code": 99991679, "message": "...", "hint": "..." } }
```
判断命令是否成功,请检查 `ok == true`(或进程退出码),**不要用 `code == 0`**。与原始 OpenAPI 响应(`{"code": 0, "msg": "ok", ...}`)不同,成功信封没有 `code``msg` 字段;`code` 只出现在错误信封的 `error` 内,含义是上游 OpenAPI 的 numeric code。完整错误分类见 [errs/ERROR_CONTRACT.md](errs/ERROR_CONTRACT.md)。
### 分页 ### 分页
```bash ```bash

View File

@@ -20,13 +20,28 @@ import (
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
func newTestApiCmd(f *cmdutil.Factory, runF func(*APIOptions) error) *cobra.Command {
cmd := NewCmdApi(f, runF)
cmd.SilenceErrors = true
cmd.SilenceUsage = true
return cmd
}
func newTestRootCmd() *cobra.Command {
return &cobra.Command{
Use: "lark-cli",
SilenceErrors: true,
SilenceUsage: true,
}
}
func TestApiCmd_FlagParsing(t *testing.T) { func TestApiCmd_FlagParsing(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -54,7 +69,7 @@ func TestApiCmd_DryRun(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--dry-run"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -77,7 +92,7 @@ func TestApiCmd_NullParamsWithPageSize(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--params", "null", "--page-size", "50", "--as", "bot", "--dry-run"})
if err := cmd.Execute(); err != nil { if err := cmd.Execute(); err != nil {
t.Fatalf("--params null with --page-size should not error, got: %v", err) t.Fatalf("--params null with --page-size should not error, got: %v", err)
@@ -98,7 +113,7 @@ func TestApiCmd_BotMode(t *testing.T) {
Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}}, Body: map[string]interface{}{"code": 0, "msg": "ok", "data": map[string]interface{}{"result": "success"}},
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -125,7 +140,7 @@ func TestApiCmd_MissingArgs(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET"}) // missing path cmd.SetArgs([]string{"GET"}) // missing path
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -138,7 +153,7 @@ func TestApiCmd_InvalidParamsJSON(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--params", "{bad"})
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -151,7 +166,7 @@ func TestApiValidArgsFunction(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
fn := cmd.ValidArgsFunction fn := cmd.ValidArgsFunction
tests := []struct { tests := []struct {
@@ -217,7 +232,7 @@ func TestNewCmdApi_StrictModeHidesAsFlag(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, SupportedIdentities: 2,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
flag := cmd.Flags().Lookup("as") flag := cmd.Flags().Lookup("as")
if flag == nil { if flag == nil {
t.Fatal("expected --as flag to be registered") t.Fatal("expected --as flag to be registered")
@@ -236,7 +251,7 @@ func TestApiCmd_PageLimitDefault(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -255,7 +270,7 @@ func TestApiCmd_ParamsAndDataBothStdinConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"}) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--params", "-", "--data", "-"})
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -272,7 +287,7 @@ func TestApiCmd_OutputAndPageAllConflict(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return apiRun(opts) return apiRun(opts)
}) })
@@ -297,7 +312,7 @@ func TestApiCmd_BinaryResponse_AutoSave(t *testing.T) {
ContentType: "application/octet-stream", ContentType: "application/octet-stream",
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"}) cmd.SetArgs([]string{"GET", "/open-apis/drive/v1/files/xxx/download", "--as", "bot"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -328,7 +343,7 @@ func TestApiCmd_PageAll_NonBatchAPI_FallbackToJSON(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users/u123", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -368,7 +383,7 @@ func TestApiCmd_PageAll_NonBatchAPI_ErrorStillOutputsJSON(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"}) cmd.SetArgs([]string{"GET", "/open-apis/im/v1/chats/oc_xxx/announcement", "--as", "bot", "--page-all"})
err := cmd.Execute() err := cmd.Execute()
// Should return an error // Should return an error
@@ -409,7 +424,7 @@ func TestApiCmd_PageAll_BatchAPI_StreamsItems(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -448,7 +463,7 @@ func TestApiCmd_PageAll_StreamBusinessErrorDoesNotDumpJSON(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -483,7 +498,7 @@ func TestApiCmd_PageAll_BatchAPI_DefaultJSONEnvelope(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := cmd.Execute(); err != nil { if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -549,8 +564,8 @@ func TestApiCmd_PageAll_DefaultJSONRunsContentSafety(t *testing.T) {
}, },
}) })
root := &cobra.Command{Use: "lark-cli"} root := newTestRootCmd()
root.AddCommand(NewCmdApi(f, nil)) root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"}) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all"})
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -600,8 +615,8 @@ func TestApiCmd_PageAll_StreamFormatRunsContentSafety(t *testing.T) {
}, },
}) })
root := &cobra.Command{Use: "lark-cli"} root := newTestRootCmd()
root.AddCommand(NewCmdApi(f, nil)) root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
if err := root.Execute(); err != nil { if err := root.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -656,8 +671,8 @@ func TestApiCmd_PageAll_StreamFormatBlockSkipsBlockedPage(t *testing.T) {
}, },
}) })
root := &cobra.Command{Use: "lark-cli"} root := newTestRootCmd()
root.AddCommand(NewCmdApi(f, nil)) root.AddCommand(newTestApiCmd(f, nil))
root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"}) root.SetArgs([]string{"api", "GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--format", "ndjson"})
err := root.Execute() err := root.Execute()
if err == nil { if err == nil {
@@ -721,7 +736,7 @@ func TestApiCmd_JqFlag_Parsing(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -741,7 +756,7 @@ func TestApiCmd_JqFlag_ShortForm(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -760,7 +775,7 @@ func TestApiCmd_JqAndOutputConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--output", "file.bin"})
@@ -791,7 +806,7 @@ func TestApiCmd_JqFilter_AppliesExpression(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"}) cmd.SetArgs([]string{"GET", "/open-apis/test/jq", "--as", "bot", "--jq", ".data.items[].name"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -812,7 +827,7 @@ func TestApiCmd_JqAndFormatConflict(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", ".data", "--format", "ndjson"})
@@ -830,7 +845,7 @@ func TestApiCmd_JqInvalidExpression(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--jq", "invalid["})
@@ -859,7 +874,7 @@ func TestApiCmd_PageAll_WithJq(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"}) cmd.SetArgs([]string{"GET", "/open-apis/contact/v3/users", "--as", "bot", "--page-all", "--jq", ".data.items[].id"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -880,7 +895,7 @@ func TestApiCmd_MethodUppercase(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -899,7 +914,7 @@ func TestApiCmd_FileFlagParsing(t *testing.T) {
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })
@@ -917,7 +932,7 @@ func TestApiCmd_FileAndOutputConflict(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"}) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "photo.jpg", "--output", "out.json"})
@@ -934,7 +949,7 @@ func TestApiCmd_FileWithGET(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"}) cmd.SetArgs([]string{"GET", "/open-apis/test", "--as", "bot", "--file", "photo.jpg"})
@@ -951,7 +966,7 @@ func TestApiCmd_FileStdinConflictWithData(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, _, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
return apiRun(opts) return apiRun(opts)
}) })
cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"}) cmd.SetArgs([]string{"POST", "/open-apis/test", "--as", "bot", "--file", "-", "--data", "-"})
@@ -974,7 +989,7 @@ func TestApiCmd_DryRunWithFile(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{ f, stdout, _, _ := cmdutil.TestFactory(t, &core.CliConfig{
AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu, AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"}) cmd.SetArgs([]string{"POST", "/open-apis/im/v1/images", "--file", "image=" + tmpFile, "--data", `{"image_type":"message"}`, "--dry-run", "--as", "bot"})
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
@@ -1015,7 +1030,7 @@ func TestApiCmd_PermissionError_DerivesFirstClassFields(t *testing.T) {
}, },
}) })
cmd := NewCmdApi(f, nil) cmd := newTestApiCmd(f, nil)
cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"}) cmd.SetArgs([]string{"GET", "/open-apis/docx/v1/documents/test", "--as", "bot"})
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
@@ -1041,7 +1056,7 @@ func TestApiCmd_JsonFlag_Accepted(t *testing.T) {
}) })
var gotOpts *APIOptions var gotOpts *APIOptions
cmd := NewCmdApi(f, func(opts *APIOptions) error { cmd := newTestApiCmd(f, func(opts *APIOptions) error {
gotOpts = opts gotOpts = opts
return nil return nil
}) })

View File

@@ -4,13 +4,11 @@
package cmd package cmd
import ( import (
"context"
"errors" "errors"
"strings" "strings"
"testing" "testing"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@@ -82,40 +80,6 @@ func TestFlagDidYouMean_UnknownFlagSuggestsAndListsValid(t *testing.T) {
} }
} }
func TestFlagDidYouMean_WikiNodeGetSuggestsNodeToken(t *testing.T) {
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
root := Build(context.Background(), cmdutil.InvocationContext{}, WithoutPlugins())
root.SetArgs([]string{
"wiki", "+node-get",
"--node", "https://feishu.cn/wiki/wikcnABC",
"--as", "user",
})
err := root.Execute()
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T (%v)", err, err)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--node" {
t.Fatalf("Params = %v, want one entry named --node", verr.Params)
}
found := false
for _, s := range verr.Params[0].Suggestions {
if s == "--node-token" {
found = true
break
}
}
if !found {
t.Fatalf("Params[0].Suggestions = %v, want --node-token", verr.Params[0].Suggestions)
}
if !strings.Contains(verr.Hint, "--node-token") {
t.Fatalf("hint = %q, want --node-token", verr.Hint)
}
}
func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) { func TestFlagDidYouMean_OtherErrorStaysGeneric(t *testing.T) {
c := &cobra.Command{Use: "demo"} c := &cobra.Command{Use: "demo"}
err := flagDidYouMean(c, errors.New("flag needs an argument: --find")) err := flagDidYouMean(c, errors.New("flag needs an argument: --find"))

View File

@@ -14,11 +14,13 @@ import (
"github.com/larksuite/cli/cmd/api" "github.com/larksuite/cli/cmd/api"
"github.com/larksuite/cli/cmd/auth" "github.com/larksuite/cli/cmd/auth"
"github.com/larksuite/cli/cmd/service" "github.com/larksuite/cli/cmd/service"
"github.com/larksuite/cli/internal/apicatalog"
"github.com/larksuite/cli/internal/build" "github.com/larksuite/cli/internal/build"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/envvars" "github.com/larksuite/cli/internal/envvars"
"github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/output" "github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/skillscheck" "github.com/larksuite/cli/internal/skillscheck"
"github.com/larksuite/cli/internal/update" "github.com/larksuite/cli/internal/update"
@@ -103,6 +105,11 @@ func parseTypedEnvelope(t *testing.T, stderr *bytes.Buffer) typedErrorEnvelope {
} }
func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command { func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.Command {
t.Helper()
return buildStrictModeIntegrationRootCmdWithCatalog(t, f, nil)
}
func buildStrictModeIntegrationRootCmdWithCatalog(t *testing.T, f *cmdutil.Factory, catalog *apicatalog.Catalog) *cobra.Command {
t.Helper() t.Helper()
rootCmd := &cobra.Command{Use: "lark-cli"} rootCmd := &cobra.Command{Use: "lark-cli"}
rootCmd.SilenceErrors = true rootCmd.SilenceErrors = true
@@ -113,7 +120,11 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
} }
rootCmd.AddCommand(auth.NewCmdAuth(f)) rootCmd.AddCommand(auth.NewCmdAuth(f))
rootCmd.AddCommand(api.NewCmdApi(f, nil)) rootCmd.AddCommand(api.NewCmdApi(f, nil))
service.RegisterServiceCommands(rootCmd, f) if catalog != nil {
service.RegisterServiceCommandsFromCatalog(context.Background(), rootCmd, f, *catalog)
} else {
service.RegisterServiceCommands(rootCmd, f)
}
shortcuts.RegisterShortcuts(rootCmd, f) shortcuts.RegisterShortcuts(rootCmd, f)
if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() { if mode := f.ResolveStrictMode(context.Background()); mode.IsActive() {
pruneForStrictMode(rootCmd, mode) pruneForStrictMode(rootCmd, mode)
@@ -121,6 +132,29 @@ func buildStrictModeIntegrationRootCmd(t *testing.T, f *cmdutil.Factory) *cobra.
return rootCmd return rootCmd
} }
func strictModeFixtureCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, []meta.Service{
{
Name: "fixture",
ServicePath: "/open-apis/fixture/v1",
Resources: map[string]meta.Resource{
"things": {
Methods: map[string]meta.Method{
"create": {
Path: "things",
HTTPMethod: "POST",
AccessTokens: []meta.Token{meta.TokenTenant},
RequestBody: map[string]meta.Field{
"name": {Type: "string"},
},
},
},
},
},
},
})
}
func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) { func newStrictModeDefaultFactory(t *testing.T, profile string, mode core.StrictMode) (*cmdutil.Factory, *bytes.Buffer, *bytes.Buffer) {
t.Helper() t.Helper()
t.Setenv(envvars.CliAppID, "") t.Setenv(envvars.CliAppID, "")
@@ -355,10 +389,11 @@ func TestIntegration_StrictModeBot_ProfileOverride_ServiceExplicitUserReturnsEnv
func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) { func TestIntegration_StrictModeUser_ProfileOverride_ServiceBotOnlyMethodReturnsEnvelope(t *testing.T) {
f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser) f, stdout, stderr := newStrictModeDefaultFactory(t, "target", core.StrictModeUser)
rootCmd := buildStrictModeIntegrationRootCmd(t, f) catalog := strictModeFixtureCatalog()
rootCmd := buildStrictModeIntegrationRootCmdWithCatalog(t, f, &catalog)
code := executeRootIntegration(t, f, rootCmd, []string{ code := executeRootIntegration(t, f, rootCmd, []string{
"im", "images", "create", "--data", `{"image_type":"message","image":"x"}`, "--dry-run", "fixture", "things", "create", "--data", `{"name":"probe"}`, "--dry-run",
}) })
if code != output.ExitValidation { if code != output.ExitValidation {

View File

@@ -65,13 +65,13 @@ func NewCmdSchema(f *cmdutil.Factory, runF func(*SchemaOptions) error) *cobra.Co
return cmd return cmd
} }
// completeSchemaPath is a thin adapter over the embedded catalog's Complete. // completeSchemaPath is a thin adapter over the schema catalog's Complete.
// It uses the embedded source so completion candidates match what `schema` // It uses the same source as schema execution so completion candidates match
// execution can resolve (both overlay-free). // what `schema` can resolve.
func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { func completeSchemaPath(f *cmdutil.Factory) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
mode := f.ResolveStrictMode(cmd.Context()) mode := f.ResolveStrictMode(cmd.Context())
completions, noSpace := registry.EmbeddedCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode)) completions, noSpace := registry.SchemaCatalog().Complete(args, toComplete, registry.FilterForStrictMode(mode))
directive := cobra.ShellCompDirectiveNoFileComp directive := cobra.ShellCompDirectiveNoFileComp
if noSpace { if noSpace {
directive |= cobra.ShellCompDirectiveNoSpace directive |= cobra.ShellCompDirectiveNoSpace
@@ -86,13 +86,19 @@ func schemaRun(opts *SchemaOptions) error {
return runSchema(out, apicatalog.ParsePath(opts.Args), mode) return runSchema(out, apicatalog.ParsePath(opts.Args), mode)
} }
// runSchema resolves the path through the embedded catalog and renders the // runSchema resolves the path through the schema catalog and renders the
// matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and // matching envelope(s). The catalog owns navigation (Resolve + MethodRefs) and
// schema owns rendering (Envelope/Envelopes); this adapter only chooses the // schema owns rendering (Envelope/Envelopes); this adapter only chooses the
// output shape — a single resolved method renders as one envelope object, // output shape — a single resolved method renders as one envelope object,
// anything broader as an array — and maps resolve failures to hints. // anything broader as an array — and maps resolve failures to hints.
func runSchema(out io.Writer, parts []string, mode core.StrictMode) error { func runSchema(out io.Writer, parts []string, mode core.StrictMode) error {
catalog := registry.EmbeddedCatalog() catalog := registry.SchemaCatalog()
if len(catalog.Services()) == 0 {
// No embedded metadata and the runtime fallback is empty too: offline
// with a cold cache, remote meta off, or an unwritable cache dir.
return errs.NewValidationError(errs.SubtypeFailedPrecondition, "No API metadata available").
WithHint("this binary has no embedded API metadata; run any command with network access to the open platform once so metadata can be fetched and cached")
}
target, err := catalog.Resolve(parts) target, err := catalog.Resolve(parts)
if err != nil { if err != nil {
return resolveError(err) return resolveError(err)

View File

@@ -102,7 +102,8 @@ func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
Long: `Update lark-cli to the latest version. Long: `Update lark-cli to the latest version.
Detects the installation method automatically: Detects the installation method automatically:
- npm install: runs npm install -g @larksuite/cli@<version> - npm install: runs npm install -g @larksuite/cli@<version>
- pnpm install: runs pnpm add -g @larksuite/cli@<version>
- manual/other: shows GitHub Releases download URL - manual/other: shows GitHub Releases download URL
Use --json for structured output (for AI agents and scripts). Use --json for structured output (for AI agents and scripts).
@@ -164,7 +165,7 @@ func updateRun(opts *UpdateOptions) error {
if !detect.CanAutoUpdate() { if !detect.CanAutoUpdate() {
return doManualUpdate(opts, io, cur, latest, detect, updater) return doManualUpdate(opts, io, cur, latest, detect, updater)
} }
return doNpmUpdate(opts, io, cur, latest, updater) return doAutoUpdate(opts, io, cur, latest, detect, updater)
} }
// --- Output helpers --- // --- Output helpers ---
@@ -226,12 +227,23 @@ func doManualUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest stri
fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n") fmt.Fprintf(io.ErrOut, "To update manually, download the latest release:\n")
fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest)) fmt.Fprintf(io.ErrOut, " Release: %s\n", releaseURL(latest))
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest) if detect.Method == selfupdate.InstallPnpm {
fmt.Fprintf(io.ErrOut, "\nOr install via pnpm (note: skills will not be synced):\n pnpm add -g %s@%s\n pnpm dlx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
} else {
fmt.Fprintf(io.ErrOut, "\nOr install via npm (note: skills will not be synced):\n npm install -g %s@%s\n npx skills add larksuite/cli -y -g # sync skills separately\n", selfupdate.NpmPackage, latest)
}
emitSkillsTextHints(io, skillsResult) emitSkillsTextHints(io, skillsResult)
return nil return nil
} }
func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, updater *selfupdate.Updater) error { func doAutoUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string, detect selfupdate.DetectResult, updater *selfupdate.Updater) error {
pm := "npm"
install := updater.RunNpmInstall
if detect.Method == selfupdate.InstallPnpm {
pm = "pnpm"
install = updater.RunPnpmInstall
}
restore, err := updater.PrepareSelfReplace() restore, err := updater.PrepareSelfReplace()
if err != nil { if err != nil {
return reportError(opts, io, "update_error", return reportError(opts, io, "update_error",
@@ -239,19 +251,19 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
} }
if !opts.JSON { if !opts.JSON {
fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via npm ...\n", cur, symArrow(), latest) fmt.Fprintf(io.ErrOut, "Updating lark-cli %s %s %s via %s ...\n", cur, symArrow(), latest, pm)
} }
npmResult := updater.RunNpmInstall(latest) npmResult := install(latest)
if npmResult.Err != nil { if npmResult.Err != nil {
restore() restore()
combined := npmResult.CombinedOutput() combined := npmResult.CombinedOutput()
if opts.JSON { if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{ output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "error": map[string]interface{}{ "ok": false, "error": map[string]interface{}{
"type": "update_error", "message": fmt.Sprintf("npm install failed: %s", npmResult.Err), "type": "update_error", "message": fmt.Sprintf("%s install failed: %s", pm, npmResult.Err),
"detail": selfupdate.Truncate(combined, maxNpmOutput), "detail": selfupdate.Truncate(combined, maxNpmOutput),
"hint": permissionHint(combined), "hint": permissionHint(combined, pm),
}, },
}) })
return output.ErrBare(output.ExitAPI) return output.ErrBare(output.ExitAPI)
@@ -263,7 +275,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
fmt.Fprint(io.ErrOut, npmResult.Stderr.String()) fmt.Fprint(io.ErrOut, npmResult.Stderr.String())
} }
fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err) fmt.Fprintf(io.ErrOut, "\n%s Update failed: %s\n", symFail(), npmResult.Err)
if hint := permissionHint(combined); hint != "" { if hint := permissionHint(combined, pm); hint != "" {
fmt.Fprintf(io.ErrOut, " %s\n", hint) fmt.Fprintf(io.ErrOut, " %s\n", hint)
} }
return output.ErrBare(output.ExitAPI) return output.ErrBare(output.ExitAPI)
@@ -274,7 +286,7 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
if err := updater.VerifyBinary(latest); err != nil { if err := updater.VerifyBinary(latest); err != nil {
restore() restore()
msg := fmt.Sprintf("new binary verification failed: %s", err) msg := fmt.Sprintf("new binary verification failed: %s", err)
hint := verificationFailureHint(updater, latest) hint := verificationFailureHint(updater, latest, pm)
if opts.JSON { if opts.JSON {
output.PrintJson(io.Out, map[string]interface{}{ output.PrintJson(io.Out, map[string]interface{}{
"ok": false, "ok": false,
@@ -304,23 +316,33 @@ func doNpmUpdate(opts *UpdateOptions, io *cmdutil.IOStreams, cur, latest string,
fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest) fmt.Fprintf(io.ErrOut, "\n%s Successfully updated lark-cli from %s to %s\n", symOK(), cur, latest)
fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL()) fmt.Fprintf(io.ErrOut, " Changelog: %s\n", changelogURL())
if skillsResult != nil { if skillsResult != nil {
fmt.Fprintf(io.ErrOut, "\nUpdating skills ...\n") skillsPM := "npx"
if detect.Method == selfupdate.InstallPnpm && detect.PnpmAvailable {
skillsPM = "pnpm dlx"
}
fmt.Fprintf(io.ErrOut, "\nUpdating skills via %s ...\n", skillsPM)
} }
emitSkillsTextHints(io, skillsResult) emitSkillsTextHints(io, skillsResult)
return nil return nil
} }
func permissionHint(npmOutput string) string { func permissionHint(pmOutput, pm string) string {
if strings.Contains(npmOutput, "EACCES") && !isWindows() { if !strings.Contains(pmOutput, "EACCES") || isWindows() {
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors" return ""
} }
return "" if pm == "pnpm" {
return "Permission denied. Ensure your pnpm global directory is writable — re-run `pnpm setup`, or see https://pnpm.io/pnpm-cli"
}
return "Permission denied. Try: sudo lark-cli update, or adjust your npm global prefix: https://docs.npmjs.com/resolving-eacces-permissions-errors"
} }
func verificationFailureHint(updater *selfupdate.Updater, latest string) string { func verificationFailureHint(updater *selfupdate.Updater, latest, pm string) string {
if updater.CanRestorePreviousVersion() { if updater.CanRestorePreviousVersion() {
return "the previous version has been restored" return "the previous version has been restored"
} }
if pm == "pnpm" {
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): pnpm add -g %s@%s && pnpm dlx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
}
return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest)) return fmt.Sprintf("automatic rollback is unavailable on this platform; reinstall manually (skills will not be synced): npm install -g %s@%s && npx skills add larksuite/cli -y -g, or download %s", selfupdate.NpmPackage, latest, releaseURL(latest))
} }

View File

@@ -57,6 +57,27 @@ func mockDetectAndNpm(t *testing.T, result selfupdate.DetectResult, npmFn func(s
t.Cleanup(func() { newUpdater = origNew }) t.Cleanup(func() { newUpdater = origNew })
} }
// mockDetectAndPnpm mirrors mockDetectAndNpm but wires the pnpm install path
// and fails the test if the npm install path is invoked.
func mockDetectAndPnpm(t *testing.T, result selfupdate.DetectResult, pnpmFn func(string) *selfupdate.NpmResult) {
t.Helper()
origNew := newUpdater
newUpdater = func() *selfupdate.Updater {
u := selfupdate.New()
u.DetectOverride = func() selfupdate.DetectResult { return result }
u.PnpmInstallOverride = pnpmFn
u.NpmInstallOverride = func(string) *selfupdate.NpmResult {
t.Errorf("npm install must not be called for a pnpm install")
return &selfupdate.NpmResult{}
}
u.VerifyOverride = func(string) error { return nil }
u.SkillsIndexFetchOverride = successfulSkillsIndexFetch()
u.SkillsCommandOverride = successfulSkillsCommand()
return u
}
t.Cleanup(func() { newUpdater = origNew })
}
func successfulSkillsIndexFetch() func() *selfupdate.NpmResult { func successfulSkillsIndexFetch() func() *selfupdate.NpmResult {
return func() *selfupdate.NpmResult { return func() *selfupdate.NpmResult {
r := &selfupdate.NpmResult{} r := &selfupdate.NpmResult{}
@@ -81,6 +102,110 @@ func successfulSkillsCommand() func(args ...string) *selfupdate.NpmResult {
} }
} }
func TestUpdatePnpm_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out := stdout.String(); !strings.Contains(out, `"action": "updated"`) {
t.Errorf("expected updated in output, got: %s", out)
}
}
func TestUpdatePnpm_Human(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{} },
)
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "via pnpm") {
t.Errorf("expected 'via pnpm' in stderr, got: %s", out)
}
if !strings.Contains(out, "Updating skills via pnpm dlx ...") {
t.Errorf("expected skills sync to report pnpm dlx launcher, got: %s", out)
}
if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message, got: %s", out)
}
}
func TestUpdatePnpm_InstallError_JSON(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _ := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{"--json"})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetectAndPnpm(t,
selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: true},
func(string) *selfupdate.NpmResult { return &selfupdate.NpmResult{Err: errors.New("pnpm boom")} },
)
err := cmd.Execute()
if err == nil {
t.Fatal("expected error exit")
}
if out := stdout.String(); !strings.Contains(out, `"ok": false`) || !strings.Contains(out, "update_error") {
t.Errorf("expected failure envelope, got: %s", out)
}
if out := stdout.String(); !strings.Contains(out, "pnpm install failed") {
t.Errorf("expected message to report pnpm as the package manager, got: %s", out)
}
}
func TestUpdatePnpm_Unavailable_ManualFallback(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, _, stderr := newTestFactory(t)
cmd := NewCmdUpdate(f)
cmd.SetArgs([]string{})
origFetch := fetchLatest
fetchLatest = func() (string, error) { return "2.0.0", nil }
defer func() { fetchLatest = origFetch }()
origVersion := currentVersion
currentVersion = func() string { return "1.0.0" }
defer func() { currentVersion = origVersion }()
mockDetect(t, selfupdate.DetectResult{Method: selfupdate.InstallPnpm, ResolvedPath: "/x/node_modules/.pnpm/@larksuite+cli@1.0.0/node_modules/@larksuite/cli/bin/lark-cli", PnpmAvailable: false})
if err := cmd.Execute(); err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stderr.String()
if !strings.Contains(out, "installed via pnpm, but pnpm is not available in PATH") {
t.Errorf("expected pnpm manual reason, got: %s", out)
}
if !strings.Contains(out, "pnpm add -g") {
t.Errorf("expected pnpm add -g hint, got: %s", out)
}
}
func TestNormalizeVersion(t *testing.T) { func TestNormalizeVersion(t *testing.T) {
tests := []struct { tests := []struct {
input string input string
@@ -266,6 +391,9 @@ func TestUpdateNpm_Human(t *testing.T) {
if !strings.Contains(out, "Successfully updated") { if !strings.Contains(out, "Successfully updated") {
t.Errorf("expected success message in stderr, got: %s", out) t.Errorf("expected success message in stderr, got: %s", out)
} }
if !strings.Contains(out, "Updating skills via npx ...") {
t.Errorf("expected skills sync to report npx launcher for npm install, got: %s", out)
}
} }
func TestUpdateForce_JSON(t *testing.T) { func TestUpdateForce_JSON(t *testing.T) {
@@ -739,9 +867,9 @@ func TestPermissionHint(t *testing.T) {
origOS := currentOS origOS := currentOS
defer func() { currentOS = origOS }() defer func() { currentOS = origOS }()
// Linux: EACCES should produce a hint with npm prefix guidance. // Linux + npm: EACCES should produce a hint with npm prefix guidance.
currentOS = "linux" currentOS = "linux"
hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'") hint := permissionHint("EACCES: permission denied, access '/usr/local/lib'", "npm")
if !strings.Contains(hint, "npm global prefix") { if !strings.Contains(hint, "npm global prefix") {
t.Errorf("expected npm prefix hint on linux, got: %s", hint) t.Errorf("expected npm prefix hint on linux, got: %s", hint)
} }
@@ -749,16 +877,25 @@ func TestPermissionHint(t *testing.T) {
t.Errorf("should not suggest raw sudo npm install, got: %s", hint) t.Errorf("should not suggest raw sudo npm install, got: %s", hint)
} }
// Linux + pnpm: EACCES should point at pnpm setup, not npm prefix/sudo.
pnpmHint := permissionHint("EACCES: permission denied, access '/Users/x/Library/pnpm'", "pnpm")
if !strings.Contains(pnpmHint, "pnpm setup") {
t.Errorf("expected pnpm setup hint, got: %s", pnpmHint)
}
if strings.Contains(pnpmHint, "npm global prefix") || strings.Contains(pnpmHint, "sudo") {
t.Errorf("pnpm hint must not reference npm prefix or sudo, got: %s", pnpmHint)
}
// Windows: EACCES hint is suppressed (no EACCES on Windows). // Windows: EACCES hint is suppressed (no EACCES on Windows).
currentOS = "windows" currentOS = "windows"
hint = permissionHint("EACCES: permission denied") hint = permissionHint("EACCES: permission denied", "npm")
if hint != "" { if hint != "" {
t.Errorf("expected empty hint on Windows, got: %s", hint) t.Errorf("expected empty hint on Windows, got: %s", hint)
} }
// Non-EACCES error: always empty. // Non-EACCES error: always empty.
currentOS = "linux" currentOS = "linux"
if got := permissionHint("some other error"); got != "" { if got := permissionHint("some other error", "npm"); got != "" {
t.Errorf("expected empty hint for non-EACCES, got: %s", got) t.Errorf("expected empty hint for non-EACCES, got: %s", got)
} }
} }

View File

@@ -72,6 +72,28 @@ other category. `error.type` is `"policy"`, `error.subtype` is one of
`challenge_required` / `access_denied`, and process exit is `6` via `challenge_required` / `access_denied`, and process exit is `6` via
`CategoryPolicy`. `CategoryPolicy`.
### Success envelope (stdout)
For contrast: success responses render to **stdout** as an
`output.Envelope` (`internal/output/envelope.go`), exit code `0`:
```json
{
"ok": true,
"identity": "user",
"data": { "guid": "e297d3d0-..." },
"meta": { "count": 1 }
}
```
Consumers must branch on `ok` (or the process exit code). The success
envelope has **no top-level `code` or `msg` field**`code` exists only
inside `error`, where it is the upstream numeric code (invariant 4).
Wrappers that follow the raw OpenAPI convention and test `code == 0`
will misclassify every successful call as a failure, which is
especially dangerous around write commands (e.g. retrying a create that
already succeeded).
## Categories ## Categories
| Category | When | Exit | Typed struct | | Category | When | Exit | Typed struct |

View File

@@ -319,7 +319,7 @@ func TestPermissionError_FullChain(t *testing.T) {
WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send"). WithHint("run: lark-cli auth login --scope %q", "mail:user_mailbox.message:send").
WithMissingScopes("mail:user_mailbox.message:send"). WithMissingScopes("mail:user_mailbox.message:send").
WithIdentity("user"). WithIdentity("user").
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth") WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=mail:user_mailbox.message:send")
if got.Category != errs.CategoryAuthorization { if got.Category != errs.CategoryAuthorization {
t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization) t.Errorf("Category = %q, want %q", got.Category, errs.CategoryAuthorization)
@@ -419,7 +419,7 @@ func TestBuilder_WireFormat(t *testing.T) {
WithHint("run lark-cli auth login --scope calendar:event:create"). WithHint("run lark-cli auth login --scope calendar:event:create").
WithMissingScopes("calendar:event:create"). WithMissingScopes("calendar:event:create").
WithIdentity("user"). WithIdentity("user").
WithConsoleURL("https://open.feishu.cn/app/cli_xxx/auth") WithConsoleURL("https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create")
buf, err := json.Marshal(e) buf, err := json.Marshal(e)
if err != nil { if err != nil {
@@ -439,7 +439,7 @@ func TestBuilder_WireFormat(t *testing.T) {
"hint": "run lark-cli auth login --scope calendar:event:create", "hint": "run lark-cli auth login --scope calendar:event:create",
"log_id": "20260520-0a1b2c3d", "log_id": "20260520-0a1b2c3d",
"identity": "user", "identity": "user",
"console_url": "https://open.feishu.cn/app/cli_xxx/auth", "console_url": "https://open.feishu.cn/page/scope-apply?clientID=cli_xxx&scopes=calendar:event:create",
"missing_scopes": []any{"calendar:event:create"}, "missing_scopes": []any{"calendar:event:create"},
} }
for k, want := range wantFields { for k, want := range wantFields {

View File

@@ -77,14 +77,10 @@ func loadService(service string) map[string]json.RawMessage {
// space→dot fallback covers domains where the two already coincide. // space→dot fallback covers domains where the two already coincide.
func commandFormResolver(service string) func(string) string { func commandFormResolver(service string) func(string) string {
byForm := map[string]string{} byForm := map[string]string{}
for _, svc := range registry.EmbeddedServicesTyped() { if svc, ok := registry.SchemaCatalog().Service(service); ok {
if svc.Name != service {
continue
}
for _, ref := range apicatalog.ServiceMethods(svc, nil) { for _, ref := range apicatalog.ServiceMethods(svc, nil) {
byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID byForm[strings.Join(ref.CommandPath()[1:], " ")] = ref.Method.ID
} }
break
} }
return func(h string) string { return func(h string) string {
h = strings.TrimSpace(h) h = strings.TrimSpace(h)

View File

@@ -6,12 +6,10 @@ package cmdutil
import ( import (
"context" "context"
"net/http" "net/http"
"os"
"reflect" "reflect"
"runtime/debug" "runtime/debug"
"strings" "strings"
"sync" "sync"
"unicode"
"github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/extension/credential"
"github.com/larksuite/cli/extension/fileio" "github.com/larksuite/cli/extension/fileio"
@@ -40,8 +38,6 @@ const (
BuildKindUnknown = "unknown" BuildKindUnknown = "unknown"
officialModulePath = "github.com/larksuite/cli" officialModulePath = "github.com/larksuite/cli"
agentTraceMaxLen = 1024
) )
// UserAgentValue returns the User-Agent value: "lark-cli/{version}". // UserAgentValue returns the User-Agent value: "lark-cli/{version}".
@@ -49,25 +45,6 @@ func UserAgentValue() string {
return SourceValue + "/" + build.Version return SourceValue + "/" + build.Version
} }
// AgentTraceValue returns a header-safe value from the
// LARKSUITE_CLI_AGENT_TRACE environment variable. It trims
// surrounding whitespace, rejects values containing any Unicode
// control character or exceeding agentTraceMaxLen, and returns ""
// for any invalid or empty value. Callers can use the result
// directly in HTTP headers without further sanitisation.
func AgentTraceValue() string {
v := strings.TrimSpace(os.Getenv(envvars.CliAgentTrace))
if v == "" || len(v) > agentTraceMaxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}
// BaseSecurityHeaders returns headers that every request must carry. // BaseSecurityHeaders returns headers that every request must carry.
func BaseSecurityHeaders() http.Header { func BaseSecurityHeaders() http.Header {
h := make(http.Header) h := make(http.Header)
@@ -75,7 +52,7 @@ func BaseSecurityHeaders() http.Header {
h.Set(HeaderVersion, build.Version) h.Set(HeaderVersion, build.Version)
h.Set(HeaderBuild, DetectBuildKind()) h.Set(HeaderBuild, DetectBuildKind())
h.Set(HeaderUserAgent, UserAgentValue()) h.Set(HeaderUserAgent, UserAgentValue())
if v := AgentTraceValue(); v != "" { if v := envvars.AgentTrace(); v != "" {
h.Set(HeaderAgentTrace, v) h.Set(HeaderAgentTrace, v)
} }
return h return h

View File

@@ -6,7 +6,6 @@ package cmdutil
import ( import (
"context" "context"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/larksuite/cli/extension/credential" "github.com/larksuite/cli/extension/credential"
@@ -264,88 +263,9 @@ func TestBaseSecurityHeaders_AllRequiredHeaders(t *testing.T) {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// AgentTraceValue / HeaderAgentTrace // HeaderAgentTrace injection (via BaseSecurityHeaders)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func TestAgentTraceValue_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty when env unset", got)
}
}
func TestAgentTraceValue_ReturnsCleanValue(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "trace-abc-123")
if got := AgentTraceValue(); got != "trace-abc-123" {
t.Fatalf("AgentTraceValue() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTraceValue_TrimsWhitespace(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " trace-trim ")
if got := AgentTraceValue(); got != "trace-trim" {
t.Fatalf("AgentTraceValue() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTraceValue_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, " ")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTraceValue_RejectsCRLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTraceValue_RejectsLF(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for LF value", got)
}
}
func TestAgentTraceValue_RejectsTab(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\tinjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for tab value", got)
}
}
func TestAgentTraceValue_RejectsControlChar(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x01injected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for control char value", got)
}
}
func TestAgentTraceValue_RejectsDEL(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "val\x7finjected")
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() = %q, want empty for DEL value", got)
}
}
func TestAgentTraceValue_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(envvars.CliAgentTrace, longVal)
if got := AgentTraceValue(); got != "" {
t.Fatalf("AgentTraceValue() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTraceValue_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(envvars.CliAgentTrace, val)
if got := AgentTraceValue(); got != val {
t.Fatalf("AgentTraceValue() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}
func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) { func TestBaseSecurityHeaders_NoAgentTraceHeaderWhenEnvUnset(t *testing.T) {
t.Setenv(envvars.CliAgentTrace, "") t.Setenv(envvars.CliAgentTrace, "")
h := BaseSecurityHeaders() h := BaseSecurityHeaders()

View File

@@ -19,6 +19,7 @@ const (
// Content safety scanning mode // Content safety scanning mode
CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE" CliContentSafetyMode = "LARKSUITE_CLI_CONTENT_SAFETY_MODE"
CliAgentName = "LARKSUITE_CLI_AGENT_NAME"
CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE" CliAgentTrace = "LARKSUITE_CLI_AGENT_TRACE"
CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE" CliProxyEnable = "LARKSUITE_CLI_PROXY_ENABLE"

36
internal/envvars/read.go Normal file
View File

@@ -0,0 +1,36 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"os"
"strings"
"unicode"
)
const (
agentNameMaxLen = 128
agentTraceMaxLen = 1024
)
func AgentName() string {
return sanitizeSingleLine(os.Getenv(CliAgentName), agentNameMaxLen)
}
func AgentTrace() string {
return sanitizeSingleLine(os.Getenv(CliAgentTrace), agentTraceMaxLen)
}
func sanitizeSingleLine(raw string, maxLen int) string {
v := strings.TrimSpace(raw)
if v == "" || len(v) > maxLen {
return ""
}
for _, r := range v {
if unicode.IsControl(r) {
return ""
}
}
return v
}

View File

@@ -0,0 +1,131 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package envvars
import (
"strings"
"testing"
)
func TestAgentName_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentName, "")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty when env unset", got)
}
}
func TestAgentName_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentName, "claude-code")
if got := AgentName(); got != "claude-code" {
t.Fatalf("AgentName() = %q, want %q", got, "claude-code")
}
}
func TestAgentName_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentName, " cursor ")
if got := AgentName(); got != "cursor" {
t.Fatalf("AgentName() = %q, want %q (whitespace trimmed)", got, "cursor")
}
}
func TestAgentName_RejectsCRLFInjection(t *testing.T) {
t.Setenv(CliAgentName, "agent\r\nX-Evil: attack")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for CR/LF value", got)
}
}
func TestAgentName_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentName, "agent\x01injected")
if got := AgentName(); got != "" {
t.Fatalf("AgentName() = %q, want empty for control char value", got)
}
}
func TestAgentName_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentNameMaxLen+1)
t.Setenv(CliAgentName, longVal)
if got := AgentName(); got != "" {
t.Fatalf("AgentName() returned non-empty for %d-byte value (max %d)", len(longVal), agentNameMaxLen)
}
}
func TestAgentTrace_EmptyWhenEnvUnset(t *testing.T) {
t.Setenv(CliAgentTrace, "")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty when env unset", got)
}
}
func TestAgentTrace_ReturnsCleanValue(t *testing.T) {
t.Setenv(CliAgentTrace, "trace-abc-123")
if got := AgentTrace(); got != "trace-abc-123" {
t.Fatalf("AgentTrace() = %q, want %q", got, "trace-abc-123")
}
}
func TestAgentTrace_TrimsWhitespace(t *testing.T) {
t.Setenv(CliAgentTrace, " trace-trim ")
if got := AgentTrace(); got != "trace-trim" {
t.Fatalf("AgentTrace() = %q, want %q (whitespace trimmed)", got, "trace-trim")
}
}
func TestAgentTrace_OnlyWhitespace_ReturnsEmpty(t *testing.T) {
t.Setenv(CliAgentTrace, " ")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for whitespace-only value", got)
}
}
func TestAgentTrace_RejectsCRLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\r\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for CR/LF value", got)
}
}
func TestAgentTrace_RejectsLF(t *testing.T) {
t.Setenv(CliAgentTrace, "val\nX-Evil: attack")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for LF value", got)
}
}
func TestAgentTrace_RejectsTab(t *testing.T) {
t.Setenv(CliAgentTrace, "val\tinjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for tab value", got)
}
}
func TestAgentTrace_RejectsControlChar(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x01injected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for control char value", got)
}
}
func TestAgentTrace_RejectsDEL(t *testing.T) {
t.Setenv(CliAgentTrace, "val\x7finjected")
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() = %q, want empty for DEL value", got)
}
}
func TestAgentTrace_RejectsOverlongValue(t *testing.T) {
longVal := strings.Repeat("a", agentTraceMaxLen+1)
t.Setenv(CliAgentTrace, longVal)
if got := AgentTrace(); got != "" {
t.Fatalf("AgentTrace() returned non-empty for %d-byte value (max %d)", len(longVal), agentTraceMaxLen)
}
}
func TestAgentTrace_AcceptsMaxLengthValue(t *testing.T) {
val := strings.Repeat("a", agentTraceMaxLen)
t.Setenv(CliAgentTrace, val)
if got := AgentTrace(); got != val {
t.Fatalf("AgentTrace() = %q, want %d-byte value accepted", got, agentTraceMaxLen)
}
}

View File

@@ -10,12 +10,14 @@ import (
"strings" "strings"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core"
) )
// ClassifyContext is the contextual data BuildAPIError uses to populate // ClassifyContext is the contextual data BuildAPIError uses to populate
// identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL). // identity-aware fields on typed errors (PermissionError.Identity / ConsoleURL).
// Identity is a plain string ("user" / "bot" / "") so this package does not // Brand and Identity are plain strings at this boundary; ConsoleURL normalizes
// depend on internal/core (which would create an import cycle). // Brand through core.ParseBrand, so callers can pass a raw brand string without
// coupling this contract to core's brand enum.
type ClassifyContext struct { type ClassifyContext struct {
Brand string // "feishu" | "lark" — drives console_url host Brand string // "feishu" | "lark" — drives console_url host
AppID string // placed in console_url AppID string // placed in console_url
@@ -444,28 +446,27 @@ func extractMissingScopes(resp map[string]any) []string {
return out return out
} }
// ConsoleURL composes the Feishu/Lark open-platform scope-grant console URL, // ConsoleURL composes the Feishu/Lark open-platform application-scope apply
// suitable for PermissionError.ConsoleURL. Empty appID → empty string. Empty // page URL (the official open-pages `/page/scope-apply` entry), suitable for
// scopes list returns the bare /auth landing page; scopes are joined with // PermissionError.ConsoleURL. Empty appID → empty string. Empty scopes list
// commas in the `q` query parameter so the console can pre-select them. // returns the page carrying only clientID; otherwise scopes are joined with
// commas in the `scopes` query parameter so the console can pre-select them.
// //
// brand is "feishu" or "lark"; unknown values default to feishu. // brand is "feishu" or "lark"; unknown values default to feishu.
func ConsoleURL(brand, appID string, scopes []string) string { func ConsoleURL(brand, appID string, scopes []string) string {
if appID == "" { if appID == "" {
return "" return ""
} }
host := "open.feishu.cn" // QueryEscape both values — clientID and scopes both sit in the query
if brand == "lark" { // string, and untrusted content must not be able to inject extra query
host = "open.larksuite.com" // parameters via `&`/`#`. The brand→host mapping is owned by core so the
} // open-platform base URL stays a single source of truth.
// PathEscape on appID — it sits in the URL path. QueryEscape on the base := fmt.Sprintf("%s/page/scope-apply?clientID=%s",
// comma-joined scopes — they sit in the `?q=` value, and untrusted scope core.ResolveOpenBaseURL(core.ParseBrand(brand)), url.QueryEscape(appID))
// content must not be able to inject extra query parameters via `&`/`#`.
pathID := url.PathEscape(appID)
if len(scopes) == 0 { if len(scopes) == 0 {
return fmt.Sprintf("https://%s/app/%s/auth", host, pathID) return base
} }
return fmt.Sprintf("https://%s/app/%s/auth?q=%s", host, pathID, url.QueryEscape(strings.Join(scopes, ","))) return base + "&scopes=" + url.QueryEscape(strings.Join(scopes, ","))
} }
func intFromAny(v any) int { func intFromAny(v any) int {

View File

@@ -422,8 +422,8 @@ func TestConsoleURL_FeishuBrand(t *testing.T) {
if !ok { if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err) t.Fatalf("expected *errs.PermissionError, got %T", err)
} }
if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/app/cli_a123") { if !strings.Contains(pe.ConsoleURL, "open.feishu.cn/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.feishu.cn prefix", pe.ConsoleURL) t.Fatalf("ConsoleURL = %q, want open.feishu.cn scope-apply page", pe.ConsoleURL)
} }
} }
@@ -434,8 +434,8 @@ func TestConsoleURL_LarkBrand(t *testing.T) {
if !ok { if !ok {
t.Fatalf("expected *errs.PermissionError, got %T", err) t.Fatalf("expected *errs.PermissionError, got %T", err)
} }
if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123") { if !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
t.Fatalf("ConsoleURL = %q, want open.larksuite.com prefix", pe.ConsoleURL) t.Fatalf("ConsoleURL = %q, want open.larksuite.com scope-apply page", pe.ConsoleURL)
} }
} }
@@ -485,35 +485,35 @@ func TestConsoleURL_EscapesDangerousChars(t *testing.T) {
name: "ampersand in scope smuggles extra param", name: "ampersand in scope smuggles extra param",
appID: "cli_good", appID: "cli_good",
scopes: []string{"scope&evil=injected"}, scopes: []string{"scope&evil=injected"},
wantInURL: []string{"q=scope%26evil%3Dinjected"}, wantInURL: []string{"scopes=scope%26evil%3Dinjected"},
denyInURL: []string{"q=scope&evil=injected"}, denyInURL: []string{"scopes=scope&evil=injected"},
}, },
{ {
name: "hash in scope splits fragment", name: "hash in scope splits fragment",
appID: "cli_good", appID: "cli_good",
scopes: []string{"scope#fragment"}, scopes: []string{"scope#fragment"},
wantInURL: []string{"q=scope%23fragment"}, wantInURL: []string{"scopes=scope%23fragment"},
denyInURL: []string{"q=scope#fragment"}, denyInURL: []string{"scopes=scope#fragment"},
}, },
{ {
name: "question mark in appID prematurely opens query", name: "question mark in appID prematurely opens query",
appID: "good?q=injected", appID: "good?q=injected",
scopes: []string{"docx:document"}, scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%3Fq=injected/auth"}, wantInURL: []string{"clientID=good%3Fq%3Dinjected"},
denyInURL: []string{"/app/good?q=injected/auth"}, denyInURL: []string{"clientID=good?q=injected"},
}, },
{ {
name: "hash in appID truncates URL", name: "hash in appID truncates URL",
appID: "good#fragment", appID: "good#fragment",
scopes: []string{"docx:document"}, scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%23fragment/auth"}, wantInURL: []string{"clientID=good%23fragment"},
denyInURL: []string{"/app/good#fragment/auth"}, denyInURL: []string{"clientID=good#fragment"},
}, },
{ {
name: "slash in appID escapes path segment", name: "slash in appID does not open a new path segment",
appID: "good/extra/segment", appID: "good/extra/segment",
scopes: []string{"docx:document"}, scopes: []string{"docx:document"},
wantInURL: []string{"/app/good%2Fextra%2Fsegment/auth"}, wantInURL: []string{"clientID=good%2Fextra%2Fsegment"},
}, },
} }
@@ -553,8 +553,8 @@ func TestPermissionError_NoViolations(t *testing.T) {
if pe.MissingScopes != nil { if pe.MissingScopes != nil {
t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes) t.Errorf("MissingScopes should be nil; got %v", pe.MissingScopes)
} }
if !strings.HasSuffix(pe.ConsoleURL, "/app/cli_a123/auth") { if !strings.HasSuffix(pe.ConsoleURL, "/page/scope-apply?clientID=cli_a123") {
t.Errorf("ConsoleURL (no scopes) = %q, want trailing /app/cli_a123/auth", pe.ConsoleURL) t.Errorf("ConsoleURL (no scopes) = %q, want trailing /page/scope-apply?clientID=cli_a123", pe.ConsoleURL)
} }
} }
@@ -758,7 +758,7 @@ func TestBuildPermissionHint_AppMissingScopeRoutesToConsole(t *testing.T) {
// at the app level — re-authenticating cannot fix it. The hint must // at the app level — re-authenticating cannot fix it. The hint must
// point to the developer console regardless of caller identity, or // point to the developer console regardless of caller identity, or
// agents will loop on `auth login` forever. // agents will loop on `auth login` forever.
consoleURL := "https://open.feishu.cn/app/cli_x/auth?q=contact%3Acontact" consoleURL := "https://open.feishu.cn/page/scope-apply?clientID=cli_x&scopes=contact%3Acontact"
for _, identity := range []string{"user", "bot", ""} { for _, identity := range []string{"user", "bot", ""} {
got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL) got := errclass.PermissionHint([]string{"contact:contact"}, identity, errs.SubtypeAppScopeNotApplied, consoleURL)
if !strings.Contains(got, "developer console") { if !strings.Contains(got, "developer console") {

View File

@@ -10,20 +10,22 @@ import "github.com/larksuite/cli/errs"
// ambiguous codes fall back to CategoryAPI via BuildAPIError. // ambiguous codes fall back to CategoryAPI via BuildAPIError.
// BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta. // BuildAPIError consumes this map via mergeCodeMeta + LookupCodeMeta.
var driveCodeMeta = map[int]CodeMeta{ var driveCodeMeta = map[int]CodeMeta{
1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error" 1061001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive "unknown error"
1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error 1061002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // params error
1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden 1061004: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // forbidden
1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted 1061007: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // file has been deleted
1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit 1061043: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file size beyond limit
1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload) 1061044: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // parent folder does not exist (upload)
1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size 1061101: {Category: errs.CategoryAPI, Subtype: errs.SubtypeQuotaExceeded}, // file quota exceeded
1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter 1062009: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // actual size inconsistent with declared size
1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied 1063001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // secure label invalid parameter
1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval 1063002: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // secure label permission denied
1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters" 1063013: {Category: errs.CategoryValidation, Subtype: errs.SubtypeFailedPrecondition}, // secure label downgrade requires approval
99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed 1069302: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // comment endpoint "Invalid or missing parameters"
9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field 99992402: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // platform field validation failed
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors 9499: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // invalid parameter type in JSON field
2200: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive tenant/internal errors
233523001: {Category: errs.CategoryAPI, Subtype: errs.SubtypeServerError, Retryable: true}, // Drive/docs transient server error
} }
func init() { mergeCodeMeta(driveCodeMeta, "drive") } func init() { mergeCodeMeta(driveCodeMeta, "drive") }

View File

@@ -114,8 +114,35 @@ func TestLookupCodeMeta_DrivePushCodes(t *testing.T) {
{1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false}, {1061004, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
{1061007, errs.CategoryAPI, errs.SubtypeNotFound, false}, {1061007, errs.CategoryAPI, errs.SubtypeNotFound, false},
{1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false}, {1061043, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
{1061101, errs.CategoryAPI, errs.SubtypeQuotaExceeded, false},
{1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false}, {1062009, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{2200, errs.CategoryAPI, errs.SubtypeServerError, true}, {2200, errs.CategoryAPI, errs.SubtypeServerError, true},
{233523001, errs.CategoryAPI, errs.SubtypeServerError, true},
}
for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {
got, ok := LookupCodeMeta(tc.code)
if !ok {
t.Fatalf("LookupCodeMeta(%d) ok=false, want true", tc.code)
}
if got.Category != tc.wantCat || got.Subtype != tc.wantSubtype || got.Retryable != tc.wantRetry {
t.Fatalf("LookupCodeMeta(%d) = %+v, want Category=%v Subtype=%v Retryable=%v",
tc.code, got, tc.wantCat, tc.wantSubtype, tc.wantRetry)
}
})
}
}
func TestLookupCodeMeta_WikiCodes(t *testing.T) {
cases := []struct {
code int
wantCat errs.Category
wantSubtype errs.Subtype
wantRetry bool
}{
{131002, errs.CategoryAPI, errs.SubtypeInvalidParameters, false},
{131005, errs.CategoryAPI, errs.SubtypeNotFound, false},
{131006, errs.CategoryAuthorization, errs.SubtypePermissionDenied, false},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) { t.Run(fmt.Sprintf("%d", tc.code), func(t *testing.T) {

View File

@@ -0,0 +1,17 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package errclass
import "github.com/larksuite/cli/errs"
// wikiCodeMeta holds wiki-service Lark code -> CodeMeta mappings observed from
// wiki shortcut failure telemetry. Keep these to wiki-wide meanings only; add
// command-specific recovery guidance at the shortcut layer.
var wikiCodeMeta = map[int]CodeMeta{
131002: {Category: errs.CategoryAPI, Subtype: errs.SubtypeInvalidParameters}, // param err: space_id is not int / invalid page_token
131005: {Category: errs.CategoryAPI, Subtype: errs.SubtypeNotFound}, // wiki node / space not found
131006: {Category: errs.CategoryAuthorization, Subtype: errs.SubtypePermissionDenied}, // wiki space/node read permission denied
}
func init() { mergeCodeMeta(wikiCodeMeta, "wiki") }

View File

@@ -7,6 +7,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"math"
"path/filepath" "path/filepath"
"sort" "sort"
"strings" "strings"
@@ -78,12 +79,15 @@ func scanText(file, source, text string, detectorFile bool) []Finding {
out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>")) out = append(out, newFinding("public_content_bearer_header", file, lineNo, source, "Authorization: Bearer <redacted>"))
} }
for _, match := range credentialURLRE.FindAllString(line, -1) { for _, match := range credentialURLRE.FindAllString(line, -1) {
if isPlaceholderCredentialURL(match) { if isPlaceholderCredentialURL(file, match) {
continue continue
} }
out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match))) out = append(out, newFinding("public_content_credential_url", file, lineNo, source, redactCredentialURL(match)))
} }
for _, match := range privateIPv4RE.FindAllString(line, -1) { for _, match := range privateIPv4RE.FindAllString(line, -1) {
if !warnForPrivateIPv4(file) {
continue
}
out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match)) out = append(out, newFinding("public_content_private_ipv4", file, lineNo, source, match))
} }
if source == "branch" && automationBranchRE.MatchString(line) { if source == "branch" && automationBranchRE.MatchString(line) {
@@ -130,6 +134,9 @@ func isCredentialAssignmentMatch(match string) bool {
if isBenignTokenField(name) && !credentialShapedValue(value) { if isBenignTokenField(name) && !credentialShapedValue(value) {
return false return false
} }
if isWeakTokenCredentialKey(name) && !weakTokenValueLooksCredentialLike(value) {
return false
}
return isExplicitCredentialKey(name) return isExplicitCredentialKey(name)
} }
@@ -284,6 +291,9 @@ func tokenLikePlaceholderValue(key, value string) bool {
if normalized == "" || credentialShapedIdentifier(normalized) { if normalized == "" || credentialShapedIdentifier(normalized) {
return false return false
} }
if authCredentialTokenKey(key) {
return false
}
return resourceTokenPlaceholderValue(value) || return resourceTokenPlaceholderValue(value) ||
maskedTokenFixturePlaceholderValue(key, normalized) || maskedTokenFixturePlaceholderValue(key, normalized) ||
isPlaceholderValue(value) || isPlaceholderValue(value) ||
@@ -313,11 +323,109 @@ func maskedTokenFixturePlaceholderValue(key, value string) bool {
return stars >= 6 && alnum > 0 return stars >= 6 && alnum > 0
} }
func isWeakTokenCredentialKey(key string) bool {
if authCredentialTokenKey(key) || isStrongTokenCredentialKey(key) {
return false
}
return key == "token" ||
strings.HasSuffix(key, "_token") ||
strings.HasSuffix(key, "-token")
}
func isStrongTokenCredentialKey(key string) bool {
parts := credentialKeyParts(strings.ReplaceAll(strings.ToLower(key), "-", "_"))
for _, phrase := range [][2]string{
{"access", "token"},
{"refresh", "token"},
{"auth", "token"},
{"bearer", "token"},
{"session", "token"},
{"service", "token"},
{"bot", "token"},
{"api", "token"},
{"secret", "token"},
} {
if hasAdjacentCredentialParts(parts, phrase[0], phrase[1]) {
return true
}
}
return false
}
func weakTokenValueLooksCredentialLike(value string) bool {
normalized := strings.ToLower(strings.Trim(value, `"'<>`))
if normalized == "" ||
isNonSecretLiteralValue(value) ||
isPlaceholderValue(value) {
return false
}
candidate := unwrapCredentialValue(normalized)
return credentialShapedIdentifier(candidate) ||
highEntropyCredentialValue(candidate) ||
commandSubstitutionLooksCredentialLike(normalized) ||
(strings.Contains(normalized, "://") &&
urlRemainderLooksCredentialLike(removeAnglePlaceholders(normalized)))
}
func unwrapCredentialValue(value string) string {
value = strings.TrimSpace(strings.Trim(value, `"'<>`))
if strings.HasPrefix(value, "${{") && strings.HasSuffix(value, "}}") {
value = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(value, "${{"), "}}"))
}
value = strings.TrimPrefix(value, "$")
value = strings.Trim(value, "%")
return strings.TrimSpace(value)
}
func highEntropyCredentialValue(value string) bool {
if len(value) < 32 {
return false
}
var hasLetter, hasDigit bool
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
hasLetter = true
case r >= '0' && r <= '9':
hasDigit = true
case r == '_' || r == '-' || r == '.' || r == '=':
default:
return false
}
}
return hasLetter && hasDigit && shannonEntropy(value) >= 3.5
}
func shannonEntropy(value string) float64 {
if value == "" {
return 0
}
counts := map[rune]int{}
for _, r := range value {
counts[r]++
}
var entropy float64
length := float64(len([]rune(value)))
for _, count := range counts {
p := float64(count) / length
entropy -= p * log2(p)
}
return entropy
}
func log2(value float64) float64 {
return math.Log(value) / math.Ln2
}
func authCredentialTokenKey(key string) bool { func authCredentialTokenKey(key string) bool {
switch strings.ReplaceAll(strings.ToLower(key), "-", "_") { switch strings.ReplaceAll(strings.ToLower(key), "-", "_") {
case "access_token", case "access_token",
"api_token",
"bot_token",
"refresh_token", "refresh_token",
"secret_token",
"session_token", "session_token",
"service_token",
"bearer_token", "bearer_token",
"auth_token", "auth_token",
"authorization_token", "authorization_token",
@@ -844,7 +952,7 @@ func looksLikeEqualityComparison(value string) bool {
return strings.HasPrefix(strings.TrimSpace(value), "=") return strings.HasPrefix(strings.TrimSpace(value), "=")
} }
func isPlaceholderCredentialURL(raw string) bool { func isPlaceholderCredentialURL(file, raw string) bool {
userInfo, ok := credentialURLUserInfo(raw) userInfo, ok := credentialURLUserInfo(raw)
if !ok { if !ok {
return false return false
@@ -853,7 +961,8 @@ func isPlaceholderCredentialURL(raw string) bool {
if !ok { if !ok {
return false return false
} }
return credentialURLPasswordPlaceholder(password) return credentialURLPasswordPlaceholder(password) ||
(sourceOrTestFixtureFile(file) && credentialURLPasswordFixture(password))
} }
func credentialURLPasswordPlaceholder(password string) bool { func credentialURLPasswordPlaceholder(password string) bool {
@@ -867,6 +976,46 @@ func credentialURLPasswordPlaceholder(password string) bool {
return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded) return angleWrappedPlaceholder(decoded) || percentWrappedPlaceholder(decoded)
} }
func credentialURLPasswordFixture(password string) bool {
normalized := strings.ToLower(strings.Trim(password, `"'`))
switch normalized {
case "p",
"pass",
"password",
"pat_abc",
"pw",
"s3cret",
"secret",
"t":
return true
default:
return false
}
}
func sourceOrTestFixtureFile(file string) bool {
normalized := filepath.ToSlash(file)
return sourceCodeFile(normalized) ||
strings.HasPrefix(normalized, "testdata/") ||
strings.HasPrefix(normalized, "fixtures/") ||
strings.Contains(normalized, "/testdata/") ||
strings.Contains(normalized, "/fixtures/")
}
func warnForPrivateIPv4(file string) bool {
normalized := filepath.ToSlash(file)
if sourceOrTestFixtureFile(normalized) {
return false
}
switch filepath.Ext(normalized) {
case ".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".toml", ".env":
return true
default:
return strings.HasPrefix(normalized, "docs/") ||
strings.HasPrefix(normalized, "skills/")
}
}
func credentialURLUserInfo(raw string) (string, bool) { func credentialURLUserInfo(raw string) (string, bool) {
schemeIdx := strings.Index(raw, "://") schemeIdx := strings.Index(raw, "://")
if schemeIdx < 0 { if schemeIdx < 0 {

View File

@@ -61,6 +61,19 @@ func TestScanFileWarnsForPrivateIPv4Examples(t *testing.T) {
} }
} }
func TestScanFileAllowsPrivateIPv4SourceFixtures(t *testing.T) {
got := ScanFile("internal/transport/warn_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@10.0.0.1:3128"`,
`target := "socks5://admin:secret@172.16.0.1:1080"`,
`host := "192.168.0.10"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_private_ipv4" {
t.Fatalf("private IPv4 source fixtures should not be public content findings: %#v", got)
}
}
}
func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) { func TestSemanticCandidateRequiresSpecificRiskSignals(t *testing.T) {
benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1) benign := semanticCandidate("docs/network.md", "file", "For a local lab, use RFC1918 example host 192.168."+"0.10 only.", 1)
if len(benign) != 0 { if len(benign) != 0 {
@@ -632,6 +645,45 @@ func TestScanFileAllowsCredentialURLPlaceholders(t *testing.T) {
} }
} }
func TestScanFileAllowsCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network_test.go", []byte(strings.Join([]string{
`proxy := "http://user:pass@proxy:8080"`,
`repo := "https://u:t@h/r.git"`,
`target := "https://attacker:pw@open.feishu.cn"`,
`proxy := "http://admin:s3cret@127.0.0.1:3128"`,
`repo := "http://x-token:PAT_abc@git.host/app_x.git"`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_credential_url" {
t.Fatalf("credential URL fixtures should not be credential URL findings: %#v", got)
}
}
}
func TestScanFileAllowsRootCredentialURLFixtures(t *testing.T) {
got := ScanFile("fixtures/network.md", []byte(strings.Join([]string{
`proxy: http://user:pass@proxy:8080`,
`repo: https://u:t@h/r.git`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_credential_url" {
t.Fatalf("root credential URL fixtures should not be credential URL findings: %#v", got)
}
}
}
func TestScanFileAllowsRootPrivateIPv4Fixtures(t *testing.T) {
got := ScanFile("testdata/network.md", []byte(strings.Join([]string{
`endpoint: http://10.0.0.1:8080`,
`redis: 192.168.1.10:6379`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_private_ipv4" {
t.Fatalf("root private IPv4 fixtures should not be private IPv4 findings: %#v", got)
}
}
}
func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) { func TestScanFileDetectsCredentialURLsWithRedactedSubstringPasswords(t *testing.T) {
got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n")) got := ScanFile("docs/config.yaml", []byte("DATABASE_URL=postgres://user:notredactedreal@example.invalid/db\n"))
for _, item := range got { for _, item := range got {
@@ -648,6 +700,7 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
"DATABASE_URL=postgres://<user>:real-secret@example.invalid/db", "DATABASE_URL=postgres://<user>:real-secret@example.invalid/db",
"DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db", "DATABASE_URL=postgres://<user>:" + stripeLike + "@example.invalid/db",
"URL=https://<user>:real-secret@example.invalid/path", "URL=https://<user>:real-secret@example.invalid/path",
"REPO=https://x-token:" + stripeLike + "@git.host/app.git",
}, "\n")+"\n")) }, "\n")+"\n"))
var count int var count int
for _, item := range got { for _, item := range got {
@@ -661,8 +714,8 @@ func TestScanFileDetectsCredentialURLsWithPlaceholderUserAndRealPassword(t *test
} }
} }
} }
if count != 3 { if count != 4 {
t.Fatalf("placeholder-user credential URL findings = %d, want 3: %#v", count, got) t.Fatalf("placeholder-user credential URL findings = %d, want 4: %#v", count, got)
} }
} }
@@ -724,6 +777,68 @@ func TestScanFileAllowsBenignJSONTokenFields(t *testing.T) {
} }
} }
func TestScanFileAllowsWeakTokenFieldsWithoutCredentialEvidence(t *testing.T) {
got := ScanFile("docs/resource-tokens.md", []byte(strings.Join([]string{
`{"token":"img_abc123"}`,
`{"token":"img_live_secret"}`,
`{"token":"img_prod_key"}`,
`token=ab********cd`,
`{"image_token":"img_live_secret"}`,
`{"data_mail_token":"mail_abc123"}`,
`{"whiteboard_token":"board_v3_example"}`,
`{"want_token":"token from callback"}`,
}, "\n")+"\n"))
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
t.Fatalf("weak token fields without credential evidence should not be credential findings: %#v", got)
}
}
}
func TestScanFileDetectsWeakTokenFieldsWithHighConfidenceCredentialValues(t *testing.T) {
githubToken := "ghp_" + "1234567890abcdef1234567890abcdef1234"
stripeToken := "sk_" + "live_1234567890abcdef"
randomToken := strings.Join([]string{
"a1b2c3d4",
"e5f6g7h8",
"i9j0k1l2",
"m3n4p5q6",
}, "")
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"token":"` + githubToken + `"}`,
`token=` + stripeToken,
`{"image_token":"` + githubToken + `"}`,
`{"token":"` + randomToken + `"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("high-confidence weak token credential findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileDetectsStrongAuthTokenKeysWithFixtureLikeValues(t *testing.T) {
got := ScanFile("docs/config.md", []byte(strings.Join([]string{
`{"access_token":"img_abc123"}`,
`{"api_token":"img_live_secret"}`,
`{"service_token":"ab********cd"}`,
`{"bot_token":"board_v3_example"}`,
}, "\n")+"\n"))
var count int
for _, item := range got {
if item.Rule == "public_content_generic_credential" {
count++
}
}
if count != 4 {
t.Fatalf("strong auth token key findings = %d, want 4: %#v", count, got)
}
}
func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) { func TestScanFileAllowsTestFixtureSecretValues(t *testing.T) {
got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n")) got := ScanFile("fixtures/calendar_meeting_test.go", []byte(`AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu,`+"\n"))
for _, item := range got { for _, item := range got {
@@ -1052,10 +1167,12 @@ func TestScanFileDetectsCredentialShapedTokenLikePlaceholderValues(t *testing.T)
} }
} }
func TestScanFileDetectsNonFixtureMinuteTokenValues(t *testing.T) { func TestScanFileAllowsNonFixtureResourceTokenValues(t *testing.T) {
got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n")) got := ScanFile("fixtures/minutes_search_test.go", []byte(`{"token":"minute_real_secret"}`+"\n"))
if !findingRules(got)["public_content_generic_credential"] { for _, item := range got {
t.Fatalf("non-fixture minute token should be credential finding: %#v", got) if item.Rule == "public_content_generic_credential" {
t.Fatalf("resource-like bare token value should not be credential finding: %#v", got)
}
} }
} }

View File

@@ -6,8 +6,7 @@ package registry
import "github.com/larksuite/cli/internal/apicatalog" import "github.com/larksuite/cli/internal/apicatalog"
// EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free) // EmbeddedCatalog returns a navigation catalog over the embedded (overlay-free)
// metadata — deterministic across machines, for `lark-cli schema`, golden tests // metadata — deterministic across machines, for golden tests and schema lint.
// and schema lint.
func EmbeddedCatalog() apicatalog.Catalog { func EmbeddedCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped()) return apicatalog.New(apicatalog.SourceEmbedded, EmbeddedServicesTyped())
} }
@@ -18,3 +17,14 @@ func EmbeddedCatalog() apicatalog.Catalog {
func RuntimeCatalog() apicatalog.Catalog { func RuntimeCatalog() apicatalog.Catalog {
return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped()) return apicatalog.New(apicatalog.SourceRuntime, ServicesTyped())
} }
// SchemaCatalog returns the embedded catalog when metadata is compiled in,
// otherwise the merged runtime catalog. Binaries built from the bare Go module
// embed only the empty meta_data_default.json stub, so the embedded view has
// nothing to resolve; the merged view is the only data such binaries have.
func SchemaCatalog() apicatalog.Catalog {
if len(EmbeddedServicesTyped()) > 0 {
return EmbeddedCatalog()
}
return RuntimeCatalog()
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/larksuite/cli/internal/apicatalog"
)
// swapEmbeddedMeta replaces the compiled-in metadata bytes for one test and
// restores them (with a full state reset) on cleanup.
func swapEmbeddedMeta(t *testing.T, data []byte) {
t.Helper()
resetInit()
orig := embeddedMetaJSON
embeddedMetaJSON = data
t.Cleanup(func() {
waitBackgroundRefresh()
embeddedMetaJSON = orig
resetInit()
})
}
func TestSchemaCatalog_EmbeddedWhenCompiledIn(t *testing.T) {
swapEmbeddedMeta(t, testCacheJSON("embedded_svc"))
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "off")
c := SchemaCatalog()
if c.Source() != apicatalog.SourceEmbedded {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceEmbedded)
}
if _, ok := c.Service("embedded_svc"); !ok {
t.Fatal("expected embedded_svc from embedded metadata")
}
}
// TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded simulates a binary built
// from the bare Go module (plugin builds): only the empty meta_data_default.json
// stub is compiled in, so SchemaCatalog must serve the merged runtime view that
// Init seeds via sync fetch.
func TestSchemaCatalog_FallsBackToRuntimeWhenNoEmbedded(t *testing.T) {
swapEmbeddedMeta(t, embeddedMetaDataDefaultJSON)
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
w.Write(testEnvelopeJSON("remote_svc"))
}))
defer ts.Close()
testMetaURL = ts.URL
c := SchemaCatalog()
if c.Source() != apicatalog.SourceRuntime {
t.Fatalf("Source = %q, want %q", c.Source(), apicatalog.SourceRuntime)
}
if _, ok := c.Service("remote_svc"); !ok {
t.Fatal("expected remote_svc from runtime fallback")
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta" "github.com/larksuite/cli/internal/meta"
"github.com/larksuite/cli/internal/update"
) )
//go:embed scope_priorities.json scope_overrides.json //go:embed scope_priorities.json scope_overrides.json
@@ -85,7 +86,9 @@ func InitWithBrand(brand core.LarkBrand) {
brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand) brandChanged := metaErr == nil && cm.Brand != "" && cm.Brand != string(brand)
if !brandChanged { if !brandChanged {
if cached, err := loadCachedMerged(); err == nil { // After a CLI upgrade the embedded data can be fresher than an old
// cache; an equal/older cache must not shadow it.
if cached, err := loadCachedMerged(); err == nil && update.IsNewer(cached.Version, embeddedVersion) {
overlayMergedServices(cached) overlayMergedServices(cached)
} }
} }

View File

@@ -0,0 +1,102 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package registry
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"time"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/meta"
)
// seedCache writes a cache file + cache meta for one service whose Title is
// marker, tagged with the given top-level data version and brand.
func seedCache(t *testing.T, dir, name, marker, version, brand string) {
t.Helper()
cDir := filepath.Join(dir, "cache")
if err := os.MkdirAll(cDir, 0700); err != nil {
t.Fatal(err)
}
reg := MergedRegistry{
Version: version,
Services: []meta.Service{{Name: name, Version: "cache", Title: marker}},
}
data, _ := json.Marshal(reg)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.json"), data, 0644); err != nil {
t.Fatal(err)
}
cm := CacheMeta{LastCheckAt: time.Now().Unix(), Version: version, Brand: brand}
mData, _ := json.Marshal(cm)
if err := os.WriteFile(filepath.Join(cDir, "remote_meta.meta.json"), mData, 0644); err != nil {
t.Fatal(err)
}
}
// initWithCache runs a fresh feishu-brand init with remote on, a high TTL and a
// recent LastCheckAt (so no refresh fires), embedded meta at embeddedVer and a
// pre-seeded cache at cacheVer — the overlay version gate is the only variable.
func initWithCache(t *testing.T, embeddedVer, cacheVer string) {
t.Helper()
embedded, _ := json.Marshal(MergedRegistry{
Version: embeddedVer,
Services: []meta.Service{{Name: "svc", Version: "embedded", Title: "EMBEDDED"}},
})
swapEmbeddedMeta(t, embedded)
tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
t.Setenv("LARKSUITE_CLI_META_TTL", "3600")
seedCache(t, tmp, "svc", "CACHE", cacheVer, "feishu")
InitWithBrand(core.BrandFeishu)
}
func titleOf(t *testing.T, name string) string {
t.Helper()
svc, ok := ServiceTyped(name)
if !ok {
t.Fatalf("service %q not loaded", name)
}
return svc.Title
}
func TestOverlayGate_EqualVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("equal version: got %q, want EMBEDDED (cache must not overlay)", got)
}
}
func TestOverlayGate_OlderCache_UsesEmbedded(t *testing.T) {
initWithCache(t, "2.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("older cache: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_NewerCache_OverlaysCache(t *testing.T) {
initWithCache(t, "1.0.0", "2.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("newer cache: got %q, want CACHE", got)
}
}
func TestOverlayGate_UnparseableCacheVersion_UsesEmbedded(t *testing.T) {
initWithCache(t, "1.0.0", "not-a-semver")
if got := titleOf(t, "svc"); got != "EMBEDDED" {
t.Errorf("unparseable cache version: got %q, want EMBEDDED", got)
}
}
func TestOverlayGate_StubEmbedded_OverlaysRealCache(t *testing.T) {
// The bare-module stub baseline is "0.0.0"; a real cache version must win so
// plugin builds without compiled meta_data.json still get remote data.
initWithCache(t, "0.0.0", "1.0.0")
if got := titleOf(t, "svc"); got != "CACHE" {
t.Errorf("stub-embedded baseline: got %q, want CACHE", got)
}
}

View File

@@ -72,9 +72,11 @@ func hasEmbeddedServices() bool {
} }
// testRegistry returns a minimal MergedRegistry with one service. // testRegistry returns a minimal MergedRegistry with one service.
// The version is a real semver newer than the embedded stub baseline ("0.0.0")
// so cache overlay passes the version gate in InitWithBrand.
func testRegistry(name string) MergedRegistry { func testRegistry(name string) MergedRegistry {
return MergedRegistry{ return MergedRegistry{
Version: "test-1.0", Version: "1.0.0",
Services: []meta.Service{ Services: []meta.Service{
{ {
Name: name, Name: name,
@@ -160,7 +162,7 @@ func TestRemoteOff_SkipsRemoteLogic(t *testing.T) {
} }
func TestCacheHit_WithinTTL(t *testing.T) { func TestCacheHit_WithinTTL(t *testing.T) {
resetInit() swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
tmp := t.TempDir() tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -197,7 +199,7 @@ func TestCacheHit_WithinTTL(t *testing.T) {
} }
func TestNetworkError_SilentDegradation(t *testing.T) { func TestNetworkError_SilentDegradation(t *testing.T) {
resetInit() swapEmbeddedMeta(t, nil) // overlay must depend only on the cache version, not the ambient embedded meta
tmp := t.TempDir() tmp := t.TempDir()
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", tmp)
t.Setenv("LARKSUITE_CLI_REMOTE_META", "on") t.Setenv("LARKSUITE_CLI_REMOTE_META", "on")
@@ -371,8 +373,8 @@ func TestFetchRemoteMerged_200(t *testing.T) {
if data == nil { if data == nil {
t.Fatal("expected non-nil data") t.Fatal("expected non-nil data")
} }
if reg.Version != "test-1.0" { if reg.Version != "1.0.0" {
t.Errorf("expected version test-1.0, got %s", reg.Version) t.Errorf("expected version 1.0.0, got %s", reg.Version)
} }
} }

View File

@@ -59,13 +59,9 @@ func BuildConsoleScopeURL(brand core.LarkBrand, appID, scope string) string {
if appID == "" || scope == "" { if appID == "" || scope == "" {
return "" return ""
} }
host := "open.feishu.cn"
if brand == core.BrandLark {
host = "open.larksuite.com"
}
return fmt.Sprintf( return fmt.Sprintf(
"https://%s/page/scope-apply?clientID=%s&scopes=%s", "%s/page/scope-apply?clientID=%s&scopes=%s",
host, core.ResolveOpenBaseURL(brand),
url.QueryEscape(appID), url.QueryEscape(appID),
url.QueryEscape(scope), url.QueryEscape(scope),
) )

View File

@@ -32,6 +32,7 @@ type InstallMethod int
const ( const (
InstallNpm InstallMethod = iota InstallNpm InstallMethod = iota
InstallPnpm
InstallManual InstallManual
) )
@@ -53,22 +54,32 @@ var (
// DetectResult holds installation detection results. // DetectResult holds installation detection results.
type DetectResult struct { type DetectResult struct {
Method InstallMethod Method InstallMethod
ResolvedPath string ResolvedPath string
NpmAvailable bool NpmAvailable bool
PnpmAvailable bool
} }
// CanAutoUpdate returns true if the CLI can update itself automatically. // CanAutoUpdate returns true if the CLI can update itself automatically.
func (d DetectResult) CanAutoUpdate() bool { func (d DetectResult) CanAutoUpdate() bool {
return d.Method == InstallNpm && d.NpmAvailable switch d.Method {
case InstallNpm:
return d.NpmAvailable
case InstallPnpm:
return d.PnpmAvailable
}
return false
} }
// ManualReason returns a human-readable explanation of why auto-update is unavailable. // ManualReason returns a human-readable explanation of why auto-update is unavailable.
func (d DetectResult) ManualReason() string { func (d DetectResult) ManualReason() string {
if d.Method == InstallNpm && !d.NpmAvailable { switch {
case d.Method == InstallNpm && !d.NpmAvailable:
return "installed via npm, but npm is not available in PATH" return "installed via npm, but npm is not available in PATH"
case d.Method == InstallPnpm && !d.PnpmAvailable:
return "installed via pnpm, but pnpm is not available in PATH"
} }
return "not installed via npm" return "not installed via npm or pnpm"
} }
// NpmResult holds the result of an npm install or skills update execution. // NpmResult holds the result of an npm install or skills update execution.
@@ -92,6 +103,7 @@ func (r *NpmResult) CombinedOutput() string {
type Updater struct { type Updater struct {
DetectOverride func() DetectResult DetectOverride func() DetectResult
NpmInstallOverride func(version string) *NpmResult NpmInstallOverride func(version string) *NpmResult
PnpmInstallOverride func(version string) *NpmResult
SkillsIndexFetchOverride func() *NpmResult SkillsIndexFetchOverride func() *NpmResult
SkillsCommandOverride func(args ...string) *NpmResult SkillsCommandOverride func(args ...string) *NpmResult
VerifyOverride func(expectedVersion string) error VerifyOverride func(expectedVersion string) error
@@ -101,17 +113,38 @@ type Updater struct {
// running binary is successfully renamed to .old. Used by // running binary is successfully renamed to .old. Used by
// CanRestorePreviousVersion to report whether rollback is possible. // CanRestorePreviousVersion to report whether rollback is possible.
backupCreated bool backupCreated bool
// detectCache memoizes the first real DetectInstallMethod result. How this
// binary was installed cannot change during a single process, so caching is
// the correct semantics — and it is required for correctness: the update
// flow mutates the install (pnpm add -g / npm install -g) before syncing
// skills, so a re-detection at skills time could resolve a now-stale
// os.Executable path and misclassify. Seeded pre-update by the first call
// (updateRun), it keeps the post-update skills launcher consistent with the
// launcher reported to the user. Not goroutine-safe; the update flow is
// sequential.
detectCache *DetectResult
} }
// New creates an Updater with default (real) behavior. // New creates an Updater with default (real) behavior.
func New() *Updater { return &Updater{} } func New() *Updater { return &Updater{} }
// DetectInstallMethod determines how the CLI was installed and whether // DetectInstallMethod determines how the CLI was installed and whether the
// npm is available for auto-update. // owning package manager is available for auto-update.
func (u *Updater) DetectInstallMethod() DetectResult { func (u *Updater) DetectInstallMethod() DetectResult {
if u.DetectOverride != nil { if u.DetectOverride != nil {
return u.DetectOverride() return u.DetectOverride()
} }
if u.detectCache != nil {
return *u.detectCache
}
result := u.detectInstallMethod()
u.detectCache = &result
return result
}
// detectInstallMethod performs the real (uncached) detection.
func (u *Updater) detectInstallMethod() DetectResult {
exe, err := vfs.Executable() exe, err := vfs.Executable()
if err != nil { if err != nil {
return DetectResult{Method: InstallManual} return DetectResult{Method: InstallManual}
@@ -120,24 +153,54 @@ func (u *Updater) DetectInstallMethod() DetectResult {
if err != nil { if err != nil {
return DetectResult{Method: InstallManual, ResolvedPath: exe} return DetectResult{Method: InstallManual, ResolvedPath: exe}
} }
_, npmErr := exec.LookPath("npm")
_, pnpmErr := exec.LookPath("pnpm")
return detectFromResolved(resolved, npmErr == nil, pnpmErr == nil)
}
// detectFromResolved classifies the resolved binary path into an install
// method and records package-manager availability. Split out from
// DetectInstallMethod so the classification is unit-testable without touching
// the filesystem or PATH.
func detectFromResolved(resolved string, npmOnPath, pnpmOnPath bool) DetectResult {
method := InstallManual method := InstallManual
if strings.Contains(resolved, "node_modules") { if strings.Contains(resolved, "node_modules") {
method = InstallNpm if containsPnpmMarker(resolved) {
} method = InstallPnpm
} else {
npmAvailable := false method = InstallNpm
if method == InstallNpm {
if _, err := exec.LookPath("npm"); err == nil {
npmAvailable = true
} }
} }
d := DetectResult{Method: method, ResolvedPath: resolved}
return DetectResult{ switch method {
Method: method, case InstallNpm:
ResolvedPath: resolved, d.NpmAvailable = npmOnPath
NpmAvailable: npmAvailable, case InstallPnpm:
d.PnpmAvailable = pnpmOnPath
} }
return d
}
// containsPnpmMarker reports whether the resolved binary path belongs to a
// pnpm-managed install. pnpm exposes two layouts: the classic virtual store
// (a ".pnpm" directory segment) and the global content-addressable store,
// whose resolved path runs through pnpm's home directory (e.g.
// "~/Library/pnpm/store/v11/links/...") — a "pnpm" segment immediately
// followed by "store". Matching only these two shapes (rather than any bare
// "pnpm" segment) avoids misclassifying an npm install that merely lives under
// a directory named "pnpm". Windows separators are normalized to "/" so the
// classification is OS-independent and unit-testable anywhere.
func containsPnpmMarker(p string) bool {
parts := strings.Split(strings.ReplaceAll(p, `\`, "/"), "/")
for i, part := range parts {
if part == ".pnpm" {
return true
}
if part == "pnpm" && i+1 < len(parts) && parts[i+1] == "store" {
return true
}
}
return false
} }
// RunNpmInstall executes npm install -g @larksuite/cli@<version>. // RunNpmInstall executes npm install -g @larksuite/cli@<version>.
@@ -163,6 +226,29 @@ func (u *Updater) RunNpmInstall(version string) *NpmResult {
return r return r
} }
// RunPnpmInstall executes pnpm add -g @larksuite/cli@<version>.
func (u *Updater) RunPnpmInstall(version string) *NpmResult {
if u.PnpmInstallOverride != nil {
return u.PnpmInstallOverride(version)
}
r := &NpmResult{}
pnpmPath, err := exec.LookPath("pnpm")
if err != nil {
r.Err = fmt.Errorf("pnpm not found in PATH: %w", err)
return r
}
ctx, cancel := context.WithTimeout(context.Background(), npmInstallTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, pnpmPath, "add", "-g", NpmPackage+"@"+version)
cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr
r.Err = cmd.Run()
if ctx.Err() == context.DeadlineExceeded {
r.Err = fmt.Errorf("pnpm install timed out after %s", npmInstallTimeout)
}
return r
}
func (u *Updater) ListOfficialSkillsIndex() *NpmResult { func (u *Updater) ListOfficialSkillsIndex() *NpmResult {
if u.SkillsIndexFetchOverride != nil { if u.SkillsIndexFetchOverride != nil {
return u.SkillsIndexFetchOverride() return u.SkillsIndexFetchOverride()
@@ -261,19 +347,40 @@ func (u *Updater) runSkillsInstall(source string, nameList []string) *NpmResult
return u.runSkillsCommand(args...) return u.runSkillsCommand(args...)
} }
// skillsInvocation decides how to launch the `skills` CLI. When the lark-cli
// itself was installed via pnpm and pnpm is available, it uses `pnpm dlx` so
// pnpm-only environments (pnpm's standalone installer bundles Node without
// putting npm/npx on PATH) can still sync skills after a self-update.
// Otherwise it uses `npx`. The npx auto-confirm flag "-y", when present as the
// leading arg, maps to `pnpm dlx`'s default non-interactive behavior and is
// dropped for the pnpm launcher. Kept pure (no exec/PATH access) so the
// launcher selection is unit-testable on any platform.
func skillsInvocation(method InstallMethod, pnpmAvailable bool, args []string) (launcher string, rest []string) {
if method == InstallPnpm && pnpmAvailable {
r := args
if len(r) > 0 && r[0] == "-y" {
r = r[1:]
}
return "pnpm", append([]string{"dlx"}, r...)
}
return "npx", args
}
func (u *Updater) runSkillsCommand(args ...string) *NpmResult { func (u *Updater) runSkillsCommand(args ...string) *NpmResult {
if u.SkillsCommandOverride != nil { if u.SkillsCommandOverride != nil {
return u.SkillsCommandOverride(args...) return u.SkillsCommandOverride(args...)
} }
r := &NpmResult{} r := &NpmResult{}
npxPath, err := exec.LookPath("npx") det := u.DetectInstallMethod()
launcher, cmdArgs := skillsInvocation(det.Method, det.PnpmAvailable, args)
binPath, err := exec.LookPath(launcher)
if err != nil { if err != nil {
r.Err = fmt.Errorf("npx not found in PATH: %w", err) r.Err = fmt.Errorf("%s not found in PATH: %w", launcher, err)
return r return r
} }
ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout) ctx, cancel := context.WithTimeout(context.Background(), skillsUpdateTimeout)
defer cancel() defer cancel()
cmd := exec.CommandContext(ctx, npxPath, args...) cmd := exec.CommandContext(ctx, binPath, cmdArgs...)
cmd.Stdout = &r.Stdout cmd.Stdout = &r.Stdout
cmd.Stderr = &r.Stderr cmd.Stderr = &r.Stderr
r.Err = cmd.Run() r.Err = cmd.Run()

View File

@@ -371,3 +371,147 @@ func TestListOfficialSkillsFallsBack(t *testing.T) {
t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1]) t.Fatalf("fallback call = %q, want larksuite/cli --list", called[1])
} }
} }
func TestContainsPnpmMarker(t *testing.T) {
cases := []struct {
path string
want bool
}{
// Classic virtual-store layout (.pnpm segment).
{"/Users/x/Library/pnpm/global/5/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\global\5\node_modules\.pnpm\@larksuite+cli@1.0.44\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// Global content-addressable store layout (pnpm 11): resolved path runs
// through the pnpm home store, a "pnpm" segment with no ".pnpm".
{"/Users/x/Library/pnpm/store/v11/links/@larksuite/cli/1.0.59/abc123/node_modules/@larksuite/cli/bin/lark-cli", true},
{"/home/x/.local/share/pnpm/store/v10/@larksuite/cli/node_modules/@larksuite/cli/bin/lark-cli", true},
{`C:\Users\x\AppData\Local\pnpm\store\v11\links\@larksuite\cli\node_modules\@larksuite\cli\bin\lark-cli.exe`, true},
// npm and non-package installs — no pnpm/.pnpm segment.
{"/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/usr/local/bin/lark-cli", false},
// Substrings that must NOT match: segment must be exactly .pnpm, or
// "pnpm" immediately followed by "store".
{"/opt/homebrew/.pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
{"/opt/pnpmfoo/node_modules/@larksuite/cli/bin/lark-cli", false},
// A bare "pnpm" directory NOT followed by "store" (e.g. an npm install
// living under a dir named pnpm) must not be misclassified as pnpm.
{"/opt/pnpm/lib/node_modules/@larksuite/cli/bin/lark-cli", false},
}
for _, c := range cases {
if got := containsPnpmMarker(c.path); got != c.want {
t.Errorf("containsPnpmMarker(%q) = %v, want %v", c.path, got, c.want)
}
}
}
func TestDetectInstallMethod_Pnpm(t *testing.T) {
u := &Updater{DetectOverride: nil}
u.DetectOverride = func() DetectResult {
// Exercise the real classification by feeding a resolved path via a small shim.
return detectFromResolved("/x/node_modules/.pnpm/@larksuite+cli@1.0.44/node_modules/@larksuite/cli/bin/lark-cli", true, true)
}
got := u.DetectInstallMethod()
if got.Method != InstallPnpm {
t.Errorf("Method = %v, want InstallPnpm", got.Method)
}
if !got.PnpmAvailable {
t.Errorf("PnpmAvailable = false, want true")
}
}
func TestDetectInstallMethod_NpmVsManual(t *testing.T) {
if m := detectFromResolved("/usr/local/lib/node_modules/@larksuite/cli/bin/lark-cli", true, false).Method; m != InstallNpm {
t.Errorf("npm path Method = %v, want InstallNpm", m)
}
if m := detectFromResolved("/usr/local/bin/lark-cli", false, false).Method; m != InstallManual {
t.Errorf("manual path Method = %v, want InstallManual", m)
}
}
func TestCanAutoUpdate_Pnpm(t *testing.T) {
if !(DetectResult{Method: InstallPnpm, PnpmAvailable: true}).CanAutoUpdate() {
t.Error("pnpm available should CanAutoUpdate")
}
if (DetectResult{Method: InstallPnpm, PnpmAvailable: false}).CanAutoUpdate() {
t.Error("pnpm unavailable should not CanAutoUpdate")
}
}
func TestManualReason_Pnpm(t *testing.T) {
if got := (DetectResult{Method: InstallPnpm, NpmAvailable: false, PnpmAvailable: false}).ManualReason(); got != "installed via pnpm, but pnpm is not available in PATH" {
t.Errorf("pnpm reason = %q", got)
}
if got := (DetectResult{Method: InstallManual}).ManualReason(); got != "not installed via npm or pnpm" {
t.Errorf("manual reason = %q", got)
}
}
func TestRunPnpmInstall_Override(t *testing.T) {
u := &Updater{PnpmInstallOverride: func(version string) *NpmResult {
r := &NpmResult{}
r.Stdout.WriteString("added @larksuite/cli@" + version)
return r
}}
got := u.RunPnpmInstall("2.0.0")
if got.Err != nil {
t.Fatalf("unexpected err: %v", got.Err)
}
if !strings.Contains(got.CombinedOutput(), "2.0.0") {
t.Errorf("output = %q, want version echoed", got.CombinedOutput())
}
}
func TestRunPnpmInstall_Error(t *testing.T) {
wantErr := errors.New("boom")
u := &Updater{PnpmInstallOverride: func(string) *NpmResult { return &NpmResult{Err: wantErr} }}
if got := u.RunPnpmInstall("2.0.0"); !errors.Is(got.Err, wantErr) {
t.Errorf("err = %v, want %v", got.Err, wantErr)
}
}
func TestSkillsInvocation(t *testing.T) {
addArgs := []string{"-y", "skills", "add", "https://open.feishu.cn", "-g", "-y"}
cases := []struct {
name string
method InstallMethod
pnpmAvailable bool
args []string
wantLauncher string
wantRest []string
}{
{"pnpm install + pnpm available → pnpm dlx, drop leading -y", InstallPnpm, true, addArgs,
"pnpm", []string{"dlx", "skills", "add", "https://open.feishu.cn", "-g", "-y"}},
{"pnpm install but pnpm unavailable → npx unchanged", InstallPnpm, false, addArgs,
"npx", addArgs},
{"npm install → npx unchanged", InstallNpm, false, addArgs,
"npx", addArgs},
{"manual install → npx unchanged", InstallManual, false, []string{"-y", "skills", "ls", "-g"},
"npx", []string{"-y", "skills", "ls", "-g"}},
{"pnpm without a leading -y → prepend dlx only", InstallPnpm, true, []string{"skills", "ls", "-g"},
"pnpm", []string{"dlx", "skills", "ls", "-g"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
gotLauncher, gotRest := skillsInvocation(c.method, c.pnpmAvailable, c.args)
if gotLauncher != c.wantLauncher {
t.Errorf("launcher = %q, want %q", gotLauncher, c.wantLauncher)
}
if strings.Join(gotRest, " ") != strings.Join(c.wantRest, " ") {
t.Errorf("rest = %v, want %v", gotRest, c.wantRest)
}
})
}
}
// TestDetectInstallMethod_Caches locks the fix for the post-update re-detection
// hazard: DetectInstallMethod must return the first (pre-update) detection on
// subsequent calls, so the skills launcher chosen after the binary is replaced
// stays consistent with what was detected — and reported — before the update.
func TestDetectInstallMethod_Caches(t *testing.T) {
u := New()
cached := DetectResult{Method: InstallPnpm, PnpmAvailable: true, ResolvedPath: "/x/pnpm/store/v11/links/@larksuite/cli/1.0.0/node_modules/@larksuite/cli/bin/lark-cli"}
u.detectCache = &cached
got := u.DetectInstallMethod()
if got.Method != InstallPnpm || !got.PnpmAvailable {
t.Errorf("expected cached pnpm result to be returned, got %+v", got)
}
}

View File

@@ -25,7 +25,7 @@ import (
const ( const (
registryURL = "https://registry.npmjs.org/@larksuite/cli/latest" registryURL = "https://registry.npmjs.org/@larksuite/cli/latest"
cacheTTL = 24 * time.Hour cacheTTL = 24 * time.Hour
fetchTimeout = 5 * time.Second fetchTimeout = 15 * time.Second
stateFile = "update-state.json" stateFile = "update-state.json"
maxBody = 256 << 10 // 256 KB maxBody = 256 << 10 // 256 KB

View File

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

View File

@@ -215,6 +215,73 @@ if ! grep -Fq "if: \${{ $fork_safe_guard }}" <<<"$section"; then
exit 1 exit 1
fi fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$dry_run_section" ||
! grep -Fq "id: e2e_domains" <<<"$dry_run_section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$dry_run_section"; then
echo "e2e-dry-run should resolve changed-file CLI E2E domains before running tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.dry_packages" <<<"$dry_run_section"; then
echo "e2e-dry-run should use resolved dry_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$dry_run_section" ||
! grep -Fq 'echo "Dry-run CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$dry_run_section"; then
echo "e2e-dry-run should pass dynamic domain output through env before shell use"
exit 1
fi
if ! grep -Fq "E2E_DRY_ROOT_PACKAGE: \${{ steps.e2e_domains.outputs.dry_root_package }}" <<<"$dry_run_section" ||
! grep -Fq 'go test -v -count=1 -timeout=5m "$E2E_DRY_ROOT_PACKAGE"' <<<"$dry_run_section"; then
echo "e2e-dry-run should run the root CLI E2E harness package without the DryRun/Regression filter"
exit 1
fi
if ! grep -Fq "No dry-run CLI E2E needed" <<<"$dry_run_section"; then
echo "e2e-dry-run should explicitly skip when domain mode is skip"
exit 1
fi
if ! grep -Fq "name: Resolve CLI E2E domains" <<<"$section" ||
! grep -Fq "id: e2e_domains" <<<"$section" ||
! grep -Fq "run: node scripts/e2e_domains.js" <<<"$section"; then
echo "e2e-live should resolve changed-file CLI E2E domains before credentials and tests"
exit 1
fi
if ! grep -Fq "steps.e2e_domains.outputs.live_packages" <<<"$section"; then
echo "e2e-live should use resolved live_packages instead of always running the full suite"
exit 1
fi
if ! grep -Fq "E2E_REASON: \${{ steps.e2e_domains.outputs.reason }}" <<<"$section" ||
! grep -Fq 'echo "Live CLI E2E domains: $E2E_MODE ($E2E_REASON)"' <<<"$section"; then
echo "e2e-live should pass dynamic domain output through env before shell use"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$dry_run_section"; then
echo "e2e-dry-run should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! awk '
/^ - name: Build lark-cli/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Build lark-cli/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should skip building lark-cli when domain mode is skip"
exit 1
fi
if ! grep -Fq "permissions:" <<<"$section" || if ! grep -Fq "permissions:" <<<"$section" ||
! grep -Fq "contents: read" <<<"$section" || ! grep -Fq "contents: read" <<<"$section" ||
! grep -Fq "checks: write" <<<"$section"; then ! grep -Fq "checks: write" <<<"$section"; then
@@ -237,13 +304,23 @@ if ! grep -Fq "::error::Missing required secrets: TEST_BOT1_APP_ID / TEST_BOT1_A
exit 1 exit 1
fi fi
if ! awk '
/^ - name: Configure bot credentials/ { in_step = 1 }
in_step && /if: \$\{\{ steps\.e2e_domains\.outputs\.mode != '\''skip'\'' \}\}/ { found = 1 }
in_step && /^ - name:/ && !/Configure bot credentials/ { in_step = 0 }
END { exit found ? 0 : 1 }
' <<<"$section"; then
echo "e2e-live should only configure bot credentials when domain mode is not skip"
exit 1
fi
if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then if grep -Fq "steps.live_e2e_credentials.outputs.configured" <<<"$section"; then
echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output" echo "e2e-live build, configure, test, and report steps should not be gated by a skip-state output"
exit 1 exit 1
fi fi
if ! grep -Fq "if: \${{ !cancelled() }}" <<<"$section"; then if ! grep -Fq "if: \${{ !cancelled() && steps.e2e_domains.outputs.mode != 'skip' }}" <<<"$section"; then
echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled" echo "e2e-live report step should run after attempted live tests unless the workflow is cancelled or domain mode is skip"
exit 1 exit 1
fi fi

54
scripts/domain-map.js Normal file
View File

@@ -0,0 +1,54 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const DOMAIN_MAP_PATH = path.join(__dirname, "domain-map.json");
const domainMap = JSON.parse(fs.readFileSync(DOMAIN_MAP_PATH, "utf8"));
function normalizeRepoPath(input) {
return String(input || "").trim().replace(/\\/g, "/").replace(/^\.\//, "").toLowerCase();
}
const pathMappingsBySpecificity = (domainMap.pathMappings || [])
.map((entry) => ({ ...entry, prefix: normalizeRepoPath(entry.prefix) }))
.sort((a, b) => b.prefix.length - a.prefix.length);
function findPathMapping(filePath) {
const normalized = normalizeRepoPath(filePath);
return pathMappingsBySpecificity.find((entry) => normalized.startsWith(entry.prefix));
}
function labelDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.labelDomains || [])] : [];
}
function e2eDomainsForPath(filePath) {
const mapping = findPathMapping(filePath);
return mapping ? [...(mapping.e2eDomains || [])] : [];
}
function matchesFullFallback(filePath) {
const normalized = normalizeRepoPath(filePath);
return (domainMap.fullFallbackPrefixes || []).some((prefix) => normalized.startsWith(prefix));
}
function isSkippablePath(filePath) {
const normalized = normalizeRepoPath(filePath);
const basename = path.posix.basename(normalized);
return (domainMap.skipPrefixes || []).some((prefix) => normalized.startsWith(prefix))
|| (domainMap.skipSuffixes || []).some((suffix) => normalized.endsWith(suffix))
|| (domainMap.skipFilenames || []).includes(basename);
}
module.exports = {
domainMap,
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
labelDomainsForPath,
matchesFullFallback,
normalizeRepoPath,
};

71
scripts/domain-map.json Normal file
View File

@@ -0,0 +1,71 @@
{
"pathMappings": [
{ "prefix": "shortcuts/im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "shortcuts/vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "shortcuts/calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "shortcuts/doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "shortcuts/sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "shortcuts/drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "shortcuts/wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "shortcuts/base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "shortcuts/mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "shortcuts/task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "shortcuts/contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "shortcuts/apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "shortcuts/markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "shortcuts/minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "shortcuts/okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "shortcuts/slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "shortcuts/note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "shortcuts/event/", "labelDomains": [], "e2eDomains": ["event"] },
{ "prefix": "skills/lark-im/", "labelDomains": ["im"], "e2eDomains": ["im"] },
{ "prefix": "skills/lark-vc/", "labelDomains": ["vc"], "e2eDomains": ["vc"] },
{ "prefix": "skills/lark-doc/", "labelDomains": ["ccm"], "e2eDomains": ["docs"] },
{ "prefix": "skills/lark-wiki/", "labelDomains": ["ccm"], "e2eDomains": ["wiki"] },
{ "prefix": "skills/lark-drive/", "labelDomains": ["ccm"], "e2eDomains": ["drive"] },
{ "prefix": "skills/lark-sheets/", "labelDomains": ["ccm"], "e2eDomains": ["sheets"] },
{ "prefix": "skills/lark-base/", "labelDomains": ["base"], "e2eDomains": ["base"] },
{ "prefix": "skills/lark-mail/", "labelDomains": ["mail"], "e2eDomains": ["mail"] },
{ "prefix": "skills/lark-calendar/", "labelDomains": ["calendar"], "e2eDomains": ["calendar"] },
{ "prefix": "skills/lark-task/", "labelDomains": ["task"], "e2eDomains": ["task"] },
{ "prefix": "skills/lark-contact/", "labelDomains": ["contact"], "e2eDomains": ["contact"] },
{ "prefix": "skills/lark-apps/", "labelDomains": [], "e2eDomains": ["apps"] },
{ "prefix": "skills/lark-markdown/", "labelDomains": [], "e2eDomains": ["markdown"] },
{ "prefix": "skills/lark-minutes/", "labelDomains": [], "e2eDomains": ["minutes"] },
{ "prefix": "skills/lark-okr/", "labelDomains": [], "e2eDomains": ["okr"] },
{ "prefix": "skills/lark-slides/", "labelDomains": [], "e2eDomains": ["slides"] },
{ "prefix": "skills/lark-note/", "labelDomains": [], "e2eDomains": ["note"] },
{ "prefix": "skills/lark-event/", "labelDomains": [], "e2eDomains": ["event"] }
],
"fullFallbackPrefixes": [
"shortcuts/common/",
"cmd/",
"internal/",
"pkg/",
"extension/",
"registry/",
"go.mod",
"go.sum",
"Makefile",
".github/workflows/",
"scripts/"
],
"skipPrefixes": [
"docs/",
".changeset/"
],
"skipSuffixes": [
".md",
".mdx",
".txt",
".rst"
],
"skipFilenames": [
"readme.md",
"readme.zh.md",
"changelog.md",
"license",
"cla.md"
]
}

224
scripts/e2e_domains.js Normal file
View File

@@ -0,0 +1,224 @@
#!/usr/bin/env node
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const fs = require("node:fs");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const {
e2eDomainsForPath,
findPathMapping,
isSkippablePath,
matchesFullFallback,
normalizeRepoPath,
} = require("./domain-map");
const ROOT = process.env.E2E_DOMAINS_ROOT || path.join(__dirname, "..");
process.chdir(ROOT);
function execLines(command, args) {
return execFileSync(command, args, { encoding: "utf8" })
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
function modulePath() {
return execLines("go", ["list", "-m"])[0];
}
function rootPackage(moduleName) {
return `${moduleName}/tests/cli_e2e`;
}
function allLivePackages(moduleName) {
return execLines("go", ["list", "./tests/cli_e2e/..."])
.filter((pkg) => pkg !== rootPackage(moduleName))
.filter((pkg) => !pkg.endsWith("/demo"));
}
function allDryPackages(moduleName) {
return allLivePackages(moduleName);
}
const domainExistsCache = new Map();
function domainExists(domain) {
if (domainExistsCache.has(domain)) {
return domainExistsCache.get(domain);
}
let exists = false;
try {
execFileSync("go", ["list", `./tests/cli_e2e/${domain}`], { stdio: "ignore" });
exists = true;
} catch {
exists = false;
}
domainExistsCache.set(domain, exists);
return exists;
}
function readChangedFiles() {
const changedFilesPath = process.env.E2E_DOMAIN_CHANGED_FILES;
if (changedFilesPath) {
return fs.readFileSync(changedFilesPath, "utf8")
.split(/\r?\n/)
.map(normalizeRepoPath)
.filter(Boolean);
}
if (process.env.GITHUB_EVENT_NAME !== "pull_request") {
return null;
}
const baseRef = process.env.GITHUB_BASE_REF || "main";
try {
execFileSync("git", ["rev-parse", "--verify", `origin/${baseRef}`], { stdio: "ignore" });
return execLines("git", ["diff", "--name-only", `origin/${baseRef}...HEAD`]).map(normalizeRepoPath);
} catch {
return null;
}
}
function addDomain(domains, domain) {
if (domain && domainExists(domain)) {
domains.add(domain);
return true;
}
return false;
}
function classifyPath(filePath, domains) {
const normalized = normalizeRepoPath(filePath);
if (!normalized) return { matched: false };
const e2eMatch = normalized.match(/^tests\/cli_e2e\/([^/]+)\//);
if (e2eMatch) {
const domain = e2eMatch[1];
if (domain === "demo") return { matched: false };
if (domainExists(domain)) {
addDomain(domains, domain);
return { matched: true };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unknown CLI E2E domain path: ${normalized}` };
}
if (normalized.startsWith("tests/cli_e2e/")) {
return { fullReason: `shared CLI E2E harness changed: ${normalized}` };
}
if (matchesFullFallback(normalized)) {
return { fullReason: `shared/runtime path changed: ${normalized}` };
}
const mappedDomains = e2eDomainsForPath(normalized);
if (mappedDomains.length > 0) {
const missingDomains = [];
for (const domain of mappedDomains) {
if (!addDomain(domains, domain)) missingDomains.push(domain);
}
if (missingDomains.length > 0) {
return { fullReason: `mapped CLI E2E domain has no package: ${missingDomains.join(",")} (${normalized})` };
}
return { matched: true };
}
if (findPathMapping(normalized)) {
return { fullReason: `mapped path has no CLI E2E package: ${normalized}` };
}
if (normalized.match(/^shortcuts\/[^/]+\//) || normalized.match(/^skills\/lark-[^/]+\//)) {
return { fullReason: `unmapped CLI E2E domain path: ${normalized}` };
}
if (isSkippablePath(normalized)) return { matched: false };
return { fullReason: `unclassified path changed: ${normalized}` };
}
function resolveDomains(changedFiles) {
const moduleName = modulePath();
const rootDryPackage = rootPackage(moduleName);
if (changedFiles === null) {
return {
mode: "full",
reason: "non-pull_request run or unavailable diff",
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
const domains = new Set();
let matchedRelevant = false;
let fullReason = "";
for (const file of changedFiles) {
const result = classifyPath(file, domains);
if (result.matched) matchedRelevant = true;
if (result.fullReason && !fullReason) fullReason = result.fullReason;
}
if (fullReason) {
return {
mode: "full",
reason: fullReason,
domains: ["all"],
dryRootPackage: rootDryPackage,
dryPackages: allDryPackages(moduleName),
livePackages: allLivePackages(moduleName),
};
}
if (matchedRelevant && domains.size > 0) {
const sortedDomains = [...domains].sort();
const packages = sortedDomains.map((domain) => `${moduleName}/tests/cli_e2e/${domain}`);
return {
mode: "subset",
reason: "business domain changes",
domains: sortedDomains,
dryRootPackage: rootDryPackage,
dryPackages: packages,
livePackages: packages,
};
}
return {
mode: "skip",
reason: "docs-only or no live CLI E2E impact",
domains: [],
dryRootPackage: "",
dryPackages: [],
livePackages: [],
};
}
function emit(resolved) {
const values = {
mode: resolved.mode,
reason: resolved.reason,
domains: resolved.domains.join(","),
dry_root_package: resolved.dryRootPackage,
dry_packages: resolved.dryPackages.join(" "),
live_packages: resolved.livePackages.join(" "),
};
const lines = Object.entries(values).map(([key, value]) => `${key}=${value}`);
console.log(lines.join("\n"));
if (process.env.GITHUB_OUTPUT) {
fs.appendFileSync(process.env.GITHUB_OUTPUT, `${lines.join("\n")}\n`);
}
}
if (require.main === module) {
emit(resolveDomains(readChangedFiles()));
}
module.exports = {
classifyPath,
readChangedFiles,
resolveDomains,
};

View File

@@ -0,0 +1,94 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { execFileSync } = require("node:child_process");
const test = require("node:test");
const scriptPath = path.join(__dirname, "e2e_domains.js");
function parseOutput(raw) {
const result = {};
for (const line of raw.trim().split(/\r?\n/)) {
const idx = line.indexOf("=");
if (idx === -1) continue;
result[line.slice(0, idx)] = line.slice(idx + 1);
}
return result;
}
function runDomains(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "e2e-domains-"));
const file = path.join(dir, "changed.txt");
fs.writeFileSync(file, `${files.join("\n")}\n`);
try {
return parseOutput(execFileSync(process.execPath, [scriptPath], {
cwd: path.join(__dirname, ".."),
encoding: "utf8",
env: { ...process.env, E2E_DOMAIN_CHANGED_FILES: file },
}));
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
test("maps shortcut changes to one business domain package", () => {
const output = runDomains(["shortcuts/im/messages/send.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "im");
assert.match(output.dry_root_package, /github\.com\/larksuite\/cli\/tests\/cli_e2e$/);
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/im/);
assert.doesNotMatch(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("maps doc shortcuts to docs package", () => {
const output = runDomains(["shortcuts/doc/update.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "docs");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/docs/);
});
test("maps direct e2e domain package changes", () => {
const output = runDomains(["tests/cli_e2e/drive/helpers.go"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "drive");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/drive/);
});
test("falls back to full for shared e2e harness changes", () => {
const output = runDomains(["tests/cli_e2e/core.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared CLI E2E harness changed/);
});
test("falls back to full for runtime changes", () => {
const output = runDomains(["cmd/root.go"]);
assert.equal(output.mode, "full");
assert.equal(output.domains, "all");
assert.match(output.reason, /shared\/runtime path changed/);
});
test("skips docs-only changes", () => {
const output = runDomains(["docs/usage.md", "README.md"]);
assert.equal(output.mode, "skip");
assert.equal(output.domains, "");
assert.equal(output.dry_root_package, "");
assert.equal(output.live_packages, "");
});
test("uses shared map for skill domain changes", () => {
const output = runDomains(["skills/lark-sheets/SKILL.md"]);
assert.equal(output.mode, "subset");
assert.equal(output.domains, "sheets");
assert.match(output.live_packages, /github\.com\/larksuite\/cli\/tests\/cli_e2e\/sheets/);
});
test("falls back to full when a mapped path has no e2e package", () => {
const output = runDomains(["shortcuts/whiteboard/export.go"]);
assert.equal(output.mode, "full");
assert.match(output.reason, /unmapped CLI E2E domain path/);
});

View File

@@ -4,6 +4,7 @@
const fs = require("node:fs/promises"); const fs = require("node:fs/promises");
const path = require("node:path"); const path = require("node:path");
const { labelDomainsForPath } = require("../domain-map");
// ============================================================================ // ============================================================================
// Constants & Configuration // Constants & Configuration
@@ -35,33 +36,6 @@ const CORE_PREFIXES = ["internal/auth/", "internal/engine/", "internal/config/",
const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]); const HEAD_BUSINESS_DOMAINS = new Set(["im", "contact", "ccm", "base", "docx"]);
const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]); const LOW_RISK_TYPES = new Set(["docs", "ci", "test", "chore"]);
// CODEOWNERS-based path to domain label mapping
// Maps shortcuts and skills paths to business domain labels
const PATH_TO_DOMAIN_MAP = {
// shortcuts
"shortcuts/im/": "im",
"shortcuts/vc/": "vc",
"shortcuts/calendar/": "calendar",
"shortcuts/doc/": "ccm",
"shortcuts/sheets/": "ccm",
"shortcuts/drive/": "ccm",
"shortcuts/wiki/": "ccm",
"shortcuts/base/": "base",
"shortcuts/mail/": "mail",
"shortcuts/task/": "task",
"shortcuts/contact/": "contact",
// skills
"skills/lark-im/": "im",
"skills/lark-vc/": "vc",
"skills/lark-doc/": "ccm",
"skills/lark-wiki/": "ccm",
"skills/lark-base/": "base",
"skills/lark-mail/": "mail",
"skills/lark-calendar/": "calendar",
"skills/lark-task/": "task",
"skills/lark-contact/": "contact",
};
const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/; const SENSITIVE_PATTERN = /(^|\/)(auth|permission|permissions|security)(\/|_|\.|$)/;
const CLASS_STANDARDS = { const CLASS_STANDARDS = {
@@ -285,13 +259,7 @@ function skillDomainForPath(filePath) {
// Get business domain label based on CODEOWNERS path mapping // Get business domain label based on CODEOWNERS path mapping
function getBusinessDomain(filePath) { function getBusinessDomain(filePath) {
const normalized = normalizePath(filePath); return labelDomainsForPath(filePath)[0] || "";
for (const [prefix, domain] of Object.entries(PATH_TO_DOMAIN_MAP)) {
if (normalized.startsWith(prefix)) {
return domain;
}
}
return "";
} }
async function detectNewShortcutDomain(files) { async function detectNewShortcutDomain(files) {

View File

@@ -8,7 +8,17 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
script="$repo_root/scripts/resolve-changed-from.sh" script="$repo_root/scripts/resolve-changed-from.sh"
tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$" tmp="${TMPDIR:-/tmp}/resolve-changed-from-test-$$"
trap 'rm -rf "$tmp"' EXIT
cleanup_tmp() {
local attempt
for attempt in 1 2 3; do
rm -rf "$tmp" && return 0
sleep 1
done
rm -rf "$tmp"
}
trap cleanup_tmp EXIT
mkdir -p "$tmp" mkdir -p "$tmp"
git_init() { git_init() {

View File

@@ -67,6 +67,26 @@ func parseAttendees(attendeesStr string, currentUserId string) ([]map[string]str
return attendees, nil return attendees, nil
} }
func attendeesIncludeRoom(attendees []map[string]string) bool {
for _, attendee := range attendees {
if attendee["type"] == "resource" || attendee["room_id"] != "" {
return true
}
}
return false
}
func guideApprovalRoomReasonError(err error, attendees []map[string]string) error {
if err == nil || !attendeesIncludeRoom(attendees) {
return err
}
p, ok := errs.ProblemOf(err)
if !ok || !strings.Contains(strings.ToLower(p.Hint), "approval_reason") {
return err
}
return withStepContext(err, "approval meeting rooms require attendees[].approval_reason; calendar +create does not expose this low-frequency field. Create the event with the raw API flow, then use `lark-cli calendar event.attendees create --as user` with attendees[].approval_reason for the room attendee.")
}
var CalendarCreate = common.Shortcut{ var CalendarCreate = common.Shortcut{
Service: "calendar", Service: "calendar",
Command: "+create", Command: "+create",
@@ -225,6 +245,7 @@ var CalendarCreate = common.Shortcut{
"need_notification": true, "need_notification": true,
}) })
if err != nil { if err != nil {
err = guideApprovalRoomReasonError(err, attendees)
// Rollback: delete the event // Rollback: delete the event
_, rollbackErr := runtime.CallAPITyped("DELETE", _, rollbackErr := runtime.CallAPITyped("DELETE",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)), fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s", validate.EncodePathSegment(calendarId), validate.EncodePathSegment(eventId)),
@@ -285,6 +306,9 @@ var CalendarCreate = common.Shortcut{
"start": startStr, "start": startStr,
"end": endStr, "end": endStr,
} }
if recurrence, _ := event["recurrence"].(string); recurrence != "" {
resultData["recurrence"] = recurrence
}
runtime.OutFormat(resultData, nil, func(w io.Writer) { runtime.OutFormat(resultData, nil, func(w io.Writer) {
var rows []map[string]interface{} var rows []map[string]interface{}

View File

@@ -0,0 +1,279 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
//
// calendar +get — get a single calendar event detail by calendar_id and event_id
package calendar
import (
"context"
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// calendarEventTime mirrors start_time / end_time in the API response.
type calendarEventTime struct {
Date string `json:"date,omitempty"`
Timestamp string `json:"timestamp,omitempty"`
Timezone string `json:"timezone,omitempty"`
}
// calendarEventVChat mirrors the vchat block in the API response.
type calendarEventVChat struct {
VCType string `json:"vc_type,omitempty"`
IconType string `json:"icon_type,omitempty"`
Description string `json:"description,omitempty"`
MeetingURL string `json:"meeting_url,omitempty"`
}
// calendarEventLocation mirrors the location block in the API response.
type calendarEventLocation struct {
Name string `json:"name,omitempty"`
Address string `json:"address,omitempty"`
Latitude float64 `json:"latitude,omitempty"`
Longitude float64 `json:"longitude,omitempty"`
}
// calendarEventReminder mirrors a reminder entry.
type calendarEventReminder struct {
Minutes int `json:"minutes"`
}
// calendarEventOrganizer mirrors event_organizer.
type calendarEventOrganizer struct {
UserID string `json:"user_id,omitempty"`
DisplayName string `json:"display_name,omitempty"`
}
// calendarEventAttachment mirrors a single attachment entry.
type calendarEventAttachment struct {
FileToken string `json:"file_token,omitempty"`
FileSize string `json:"file_size,omitempty"`
Name string `json:"name,omitempty"`
}
// calendarEventCheckInTime mirrors check_in_start_time / check_in_end_time.
type calendarEventCheckInTime struct {
TimeType string `json:"time_type,omitempty"`
Duration int `json:"duration"`
}
// calendarEventCheckIn mirrors event_check_in.
type calendarEventCheckIn struct {
EnableCheckIn bool `json:"enable_check_in"`
CheckInStartTime *calendarEventCheckInTime `json:"check_in_start_time,omitempty"`
CheckInEndTime *calendarEventCheckInTime `json:"check_in_end_time,omitempty"`
NeedNotifyAttendees bool `json:"need_notify_attendees"`
}
// calendarEvent mirrors the event object inside the API response.
type calendarEvent struct {
EventID string `json:"event_id,omitempty"`
OrganizerCalendarID string `json:"organizer_calendar_id,omitempty"`
Summary string `json:"summary,omitempty"`
Description string `json:"description,omitempty"`
StartTime *calendarEventTime `json:"start_time,omitempty"`
EndTime *calendarEventTime `json:"end_time,omitempty"`
VChat *calendarEventVChat `json:"vchat,omitempty"`
Visibility string `json:"visibility,omitempty"`
AttendeeAbility string `json:"attendee_ability,omitempty"`
FreeBusyStatus string `json:"free_busy_status,omitempty"`
SelfRsvpStatus string `json:"self_rsvp_status,omitempty"`
Location *calendarEventLocation `json:"location,omitempty"`
Color int `json:"color,omitempty"`
Reminders []calendarEventReminder `json:"reminders,omitempty"`
Recurrence string `json:"recurrence,omitempty"`
Status string `json:"status,omitempty"`
IsException bool `json:"is_exception,omitempty"`
RecurringEventID string `json:"recurring_event_id,omitempty"`
CreateTime string `json:"create_time,omitempty"`
EventOrganizer *calendarEventOrganizer `json:"event_organizer,omitempty"`
AppLink string `json:"app_link,omitempty"`
Attachments []calendarEventAttachment `json:"attachments,omitempty"`
EventCheckIn *calendarEventCheckIn `json:"event_check_in,omitempty"`
}
// parseCalendarEvent decodes the API response data into a typed calendarEvent.
func parseCalendarEvent(data map[string]any) (*calendarEvent, error) {
rawEvent, ok := data["event"]
if !ok || rawEvent == nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response missing 'event' field")
}
raw, err := json.Marshal(rawEvent)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: marshal failed: %s", err).WithCause(err)
}
var event calendarEvent
if err := json.Unmarshal(raw, &event); err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event response: unmarshal failed: %s", err).WithCause(err)
}
return &event, nil
}
// buildCalendarEventOutput converts the typed event into the output map and
// applies the four transformation rules:
// 1. create_time -> RFC3339
// 2. start_time / end_time timestamp -> datetime (RFC3339), drop timestamp
// 3. flatten event into the top-level result
// 4. when status != "cancelled", drop status (and adjust all-day end date)
func buildCalendarEventOutput(event *calendarEvent) (map[string]interface{}, error) {
raw, err := json.Marshal(event)
if err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event marshal failed: %s", err).WithCause(err)
}
var out map[string]interface{}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, errs.NewInternalError(errs.SubtypeInvalidResponse, "calendar event unmarshal failed: %s", err).WithCause(err)
}
if ctStr, ok := out["create_time"].(string); ok && ctStr != "" {
if ts, err := strconv.ParseInt(ctStr, 10, 64); err == nil {
out["create_time"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
}
}
if startMap, ok := out["start_time"].(map[string]interface{}); ok {
if tsStr, ok := startMap["timestamp"].(string); ok && tsStr != "" {
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
startMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
delete(startMap, "timestamp")
}
}
}
if endMap, ok := out["end_time"].(map[string]interface{}); ok {
if tsStr, ok := endMap["timestamp"].(string); ok && tsStr != "" {
if ts, err := strconv.ParseInt(tsStr, 10, 64); err == nil {
endMap["datetime"] = time.Unix(ts, 0).Local().Format(time.RFC3339)
delete(endMap, "timestamp")
}
}
// All-day event: end date is exclusive in the API; rewind by 1s and reformat.
if dt, _ := endMap["datetime"].(string); dt == "" {
if dateStr, ok := endMap["date"].(string); ok && dateStr != "" {
if t, err := time.ParseInLocation("2006-01-02", dateStr, time.UTC); err == nil {
endMap["date"] = t.Add(-1 * time.Second).Format("2006-01-02")
}
}
}
}
if status, _ := out["status"].(string); status != "cancelled" {
delete(out, "status")
}
return out, nil
}
// CalendarGet gets a single calendar event detail.
var CalendarGet = common.Shortcut{
Service: "calendar",
Command: "+get",
Description: "Get a single calendar event detail by calendar-id and event-id",
Risk: "read",
Scopes: []string{"calendar:calendar.event:read"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "calendar-id", Desc: "calendar ID (default: primary)"},
{Name: "event-id", Desc: "event ID", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := rejectCalendarAutoBotFallback(runtime); err != nil {
return err
}
for _, flag := range []string{"calendar-id", "event-id"} {
if val := strings.TrimSpace(runtime.Str(flag)); val != "" {
if err := common.RejectDangerousCharsTyped("--"+flag, val); err != nil {
return err
}
}
}
eventId := strings.TrimSpace(runtime.Str("event-id"))
if eventId == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "event-id cannot be empty").WithParam("--event-id")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
d := common.NewDryRunAPI()
switch calendarId {
case "":
d.Desc("(calendar-id omitted) Will use primary calendar")
calendarId = "<primary>"
case "primary":
calendarId = "<primary>"
}
eventId := strings.TrimSpace(runtime.Str("event-id"))
return d.
GET("/open-apis/calendar/v4/calendars/:calendar_id/events/:event_id").
Set("calendar_id", calendarId).
Set("event_id", eventId)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
calendarId := strings.TrimSpace(runtime.Str("calendar-id"))
if calendarId == "" {
calendarId = PrimaryCalendarIDStr
}
eventId := strings.TrimSpace(runtime.Str("event-id"))
data, err := runtime.CallAPITyped("GET",
fmt.Sprintf("/open-apis/calendar/v4/calendars/%s/events/%s",
validate.EncodePathSegment(calendarId),
validate.EncodePathSegment(eventId)),
nil, nil)
if err != nil {
return err
}
event, err := parseCalendarEvent(data)
if err != nil {
return err
}
out, err := buildCalendarEventOutput(event)
if err != nil {
return err
}
runtime.OutFormat(out, nil, func(w io.Writer) {
summary, _ := out["summary"].(string)
if summary == "" {
summary = "(untitled)"
}
startMap, _ := out["start_time"].(map[string]interface{})
endMap, _ := out["end_time"].(map[string]interface{})
startStr, _ := startMap["datetime"].(string)
if startStr == "" {
startStr, _ = startMap["date"].(string)
}
endStr, _ := endMap["datetime"].(string)
if endStr == "" {
endStr, _ = endMap["date"].(string)
}
eventIdOut, _ := out["event_id"].(string)
freeBusyStatus, _ := out["free_busy_status"].(string)
selfRsvpStatus, _ := out["self_rsvp_status"].(string)
row := map[string]interface{}{
"event_id": eventIdOut,
"summary": summary,
"start": startStr,
"end": endStr,
"free_busy_status": freeBusyStatus,
"self_rsvp_status": selfRsvpStatus,
}
output.PrintTable(w, []map[string]interface{}{row})
fmt.Fprintln(w)
})
return nil
},
}

View File

@@ -673,6 +673,76 @@ func TestCreate_WithAttendees_InvalidParamsWithDetail_RollsBack(t *testing.T) {
} }
} }
func TestCreate_ApprovalRoomMissingReason_GuidesRawAttendeesAPI(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_approval_room",
"summary": "Approval Room",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/events/evt_approval_room/attendees",
Body: map[string]interface{}{
"code": codeInvalidParamsWithDetail,
"msg": "invalid params",
"error": map[string]interface{}{
"details": []interface{}{
map[string]interface{}{"value": "attendees[0].approval_reason is required for approval meeting rooms"},
},
},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/events/evt_approval_room",
Body: map[string]interface{}{"code": 0, "msg": "ok"},
})
err := mountAndRun(t, CalendarCreate, []string{
"+create",
"--summary", "Approval Room",
"--start", "2025-03-21T00:00:00+08:00",
"--end", "2025-03-21T01:00:00+08:00",
"--calendar-id", "cal_test123",
"--attendee-ids", "omm_room1",
"--as", "user",
}, f, nil)
if err == nil {
t.Fatal("expected error for approval room missing approval_reason, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf returned !ok for %T", err)
}
if p.Category != errs.CategoryAPI {
t.Errorf("category=%q, want %q", p.Category, errs.CategoryAPI)
}
if p.Subtype != errs.SubtypeInvalidParameters {
t.Errorf("subtype=%q, want %q", p.Subtype, errs.SubtypeInvalidParameters)
}
if p.Code != codeInvalidParamsWithDetail {
t.Errorf("code=%d, want %d", p.Code, codeInvalidParamsWithDetail)
}
for _, want := range []string{"approval_reason", "calendar event.attendees create", "--as user", "rolled back successfully"} {
if !strings.Contains(p.Hint, want) {
t.Errorf("hint should contain %q, got: %q", want, p.Hint)
}
}
}
// When the add-attendees call fails AND the rollback DELETE also fails, the // When the add-attendees call fails AND the rollback DELETE also fails, the
// primary error stays the add failure (classification preserved) and the Hint // primary error stays the add failure (classification preserved) and the Hint
// must surface BOTH the rollback failure reason and the orphan event_id so the // must surface BOTH the rollback failure reason and the orphan event_id so the
@@ -2234,17 +2304,17 @@ func TestResolveStartEnd_ExplicitValues(t *testing.T) {
// Shortcuts() registration test // Shortcuts() registration test
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
func TestShortcuts_Returns9(t *testing.T) { func TestShortcuts_Returns10(t *testing.T) {
shortcuts := Shortcuts() shortcuts := Shortcuts()
if len(shortcuts) != 9 { if len(shortcuts) != 10 {
t.Fatalf("expected 9 shortcuts, got %d", len(shortcuts)) t.Fatalf("expected 10 shortcuts, got %d", len(shortcuts))
} }
names := map[string]bool{} names := map[string]bool{}
for _, s := range shortcuts { for _, s := range shortcuts {
names[s.Command] = true names[s.Command] = true
} }
for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion"} { for _, want := range []string{"+agenda", "+create", "+update", "+freebusy", "+room-find", "+rsvp", "+suggestion", "+get"} {
if !names[want] { if !names[want] {
t.Errorf("missing shortcut %s", want) t.Errorf("missing shortcut %s", want)
} }
@@ -3108,3 +3178,193 @@ func TestSuggestion_RejectsDangerousTimezone_Typed(t *testing.T) {
t.Errorf("param=%q, want --timezone", ve.Param) t.Errorf("param=%q, want --timezone", ve.Param)
} }
} }
// ---------------------------------------------------------------------------
// CalendarGet tests
// ---------------------------------------------------------------------------
func TestGet_Success_FlattensAndConvertsTimes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_001",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_001",
"summary": "Daily Sync",
"create_time": "1602504000",
"start_time": map[string]interface{}{
"timestamp": "1742515200",
"timezone": "Asia/Shanghai",
},
"end_time": map[string]interface{}{
"timestamp": "1742518800",
"timezone": "Asia/Shanghai",
},
"status": "confirmed",
},
},
},
})
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--calendar-id", "cal_test123",
"--event-id", "evt_001",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// Expect flattened — fields appear directly under "data", not under "data.event"
if strings.Contains(out, "\"event\": {") {
t.Errorf("payload should be flattened (no event wrapper), got: %s", out)
}
if !strings.Contains(out, "\"event_id\": \"evt_001\"") {
t.Errorf("expected event_id in output, got: %s", out)
}
// status=confirmed should be dropped
if strings.Contains(out, "\"status\": \"confirmed\"") {
t.Errorf("status should be dropped when not cancelled, got: %s", out)
}
// timestamp must be replaced with datetime
if strings.Contains(out, "\"timestamp\":") {
t.Errorf("timestamp should be replaced with datetime, got: %s", out)
}
if !strings.Contains(out, "\"datetime\":") {
t.Errorf("expected datetime in output, got: %s", out)
}
// create_time must be RFC3339 (contain 'T' and timezone)
if !strings.Contains(out, "\"create_time\": \"2020-10-12T") {
t.Errorf("expected RFC3339 create_time, got: %s", out)
}
}
func TestGet_CancelledStatus_PreservesStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_002",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_002",
"summary": "Cancelled Meeting",
"create_time": "1602504000",
"start_time": map[string]interface{}{"timestamp": "1742515200"},
"end_time": map[string]interface{}{"timestamp": "1742518800"},
"status": "cancelled",
},
},
},
})
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--calendar-id", "cal_test123",
"--event-id", "evt_002",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "\"status\": \"cancelled\"") {
t.Errorf("status should be preserved when cancelled, got: %s", out)
}
}
func TestGet_AllDayEvent_AdjustsEndDate(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
// All-day event: start 2025-03-21, end 2025-03-22 (exclusive in API).
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_003",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{
"event": map[string]interface{}{
"event_id": "evt_003",
"summary": "All-day",
"start_time": map[string]interface{}{"date": "2025-03-21"},
"end_time": map[string]interface{}{"date": "2025-03-22"},
"status": "confirmed",
},
},
},
})
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--calendar-id", "cal_test123",
"--event-id", "evt_003",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
// end date 2025-03-22 should rewind by 1s -> 2025-03-21
if !strings.Contains(out, "\"date\": \"2025-03-21\"") {
t.Errorf("expected end date adjusted to 2025-03-21, got: %s", out)
}
}
func TestGet_EmptyEventID_Typed(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--event-id", " ",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("want error for empty event-id")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Fatalf("want *errs.ValidationError, got %T", err)
}
if ve.Param != "--event-id" {
t.Errorf("param=%q, want --event-id", ve.Param)
}
}
func TestGet_MissingEventField_TypedInternal(t *testing.T) {
f, _, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/calendar/v4/calendars/cal_test123/events/evt_404",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{},
},
})
err := mountAndRun(t, CalendarGet, []string{
"+get",
"--calendar-id", "cal_test123",
"--event-id", "evt_404",
"--as", "bot",
}, f, nil)
if err == nil {
t.Fatal("want error when event field is missing")
}
var ie *errs.InternalError
if !errors.As(err, &ie) {
t.Fatalf("want *errs.InternalError, got %T", err)
}
if ie.Subtype != errs.SubtypeInvalidResponse {
t.Errorf("subtype=%q, want invalid_response", ie.Subtype)
}
}

View File

@@ -17,5 +17,6 @@ func Shortcuts() []common.Shortcut {
CalendarSuggestion, CalendarSuggestion,
CalendarMeeting, CalendarMeeting,
CalendarSearchEvent, CalendarSearchEvent,
CalendarGet,
} }
} }

View File

@@ -23,8 +23,8 @@ var DocMediaUpload = common.Shortcut{
AuthTypes: []string{"user", "bot"}, AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true}, {Name: "file", Desc: "local file path (files > 20MB use multipart upload automatically)", Required: true},
{Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard", Required: true}, {Name: "parent-type", Desc: "parent type: docx_image | docx_file | whiteboard | mindnote_image", Required: true},
{Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard)", Required: true}, {Name: "parent-node", Desc: "parent node ID (block_id for docx, board_token for whiteboard, mindnote token for mindnote)", Required: true},
{Name: "doc-id", Desc: "document ID (for drive_route_token)"}, {Name: "doc-id", Desc: "document ID (for drive_route_token)"},
}, },
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {

View File

@@ -0,0 +1,261 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type docsHistoryListSpec struct {
Doc documentRef
PageSize int
PageToken string
}
type docsHistoryRevertSpec struct {
Doc documentRef
HistoryVersionID string
WaitTimeoutMs int
}
type docsHistoryRevertStatusSpec struct {
Doc documentRef
TaskID string
}
func parseDocsHistoryDocRef(raw, shortcut string) (documentRef, error) {
ref, err := parseDocumentRef(raw)
if err != nil {
return documentRef{}, err
}
if ref.Kind == "doc" {
return documentRef{}, errs.NewValidationError(errs.SubtypeInvalidArgument, "docs %s only supports docx documents; use a docx token/URL or a wiki URL that resolves to docx", shortcut).WithParam("--doc")
}
return ref, nil
}
func validateDocsHistoryPageSize(pageSize int) error {
if pageSize < 1 || pageSize > 20 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --page-size %d: must be between 1 and 20", pageSize).WithParam("--page-size")
}
return nil
}
func validateDocsHistoryVersionID(historyVersionID string) error {
version, err := strconv.ParseInt(strings.TrimSpace(historyVersionID), 10, 64)
if err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id").WithCause(err)
}
if version <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--history-version-id must be a positive integer string returned by docs +history-list").WithParam("--history-version-id")
}
return nil
}
func validateDocsHistoryWaitTimeout(timeoutMs int) error {
if timeoutMs < 0 || timeoutMs > 30000 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --wait-timeout-ms %d: must be between 0 and 30000", timeoutMs).WithParam("--wait-timeout-ms")
}
return nil
}
func docsHistoryListParams(spec docsHistoryListSpec) map[string]interface{} {
params := map[string]interface{}{
"page_size": spec.PageSize,
}
if spec.PageToken != "" {
params["page_token"] = spec.PageToken
}
return params
}
func docsHistoryRevertBody(spec docsHistoryRevertSpec) map[string]interface{} {
return map[string]interface{}{
"history_version_id": spec.HistoryVersionID,
"wait_timeout_ms": spec.WaitTimeoutMs,
}
}
func docsHistoryStatusParams(spec docsHistoryRevertStatusSpec) map[string]interface{} {
return map[string]interface{}{
"task_id": spec.TaskID,
}
}
func docsHistoryAPIPath(docToken, suffix string) string {
return fmt.Sprintf("/open-apis/docs_ai/v1/documents/%s/%s", validate.EncodePathSegment(docToken), suffix)
}
var DocsHistoryList = common.Shortcut{
Service: "docs",
Command: "+history-list",
Description: "List Lark document history versions",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-list"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "page-size", Type: "int", Default: "20", Desc: "history entries to return, range 1-20"},
{Name: "page-token", Desc: "pagination token from the previous page's page_token"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list"); err != nil {
return err
}
return validateDocsHistoryPageSize(runtime.Int("page-size"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
spec := docsHistoryListSpec{
Doc: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
return common.NewDryRunAPI().
Desc("OpenAPI: list document history versions").
GET("/open-apis/docs_ai/v1/documents/:document_id/histories").
Set("document_id", spec.Doc.Token).
Params(docsHistoryListParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-list")
spec := docsHistoryListSpec{
Doc: ref,
PageSize: runtime.Int("page-size"),
PageToken: strings.TrimSpace(runtime.Str("page-token")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
docsHistoryAPIPath(spec.Doc.Token, "histories"),
docsHistoryListParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
var DocsHistoryRevert = common.Shortcut{
Service: "docs",
Command: "+history-revert",
Description: "Revert a Lark document to a historical version",
Risk: "write",
Scopes: []string{"docx:document:write_only", "docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-revert"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "history-version-id", Desc: "history_version_id from docs +history-list to revert to", Required: true},
{Name: "wait-timeout-ms", Type: "int", Default: "30000", Desc: "milliseconds to wait for revert completion before returning, range 0-30000"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert"); err != nil {
return err
}
if err := validateDocsHistoryVersionID(runtime.Str("history-version-id")); err != nil {
return err
}
return validateDocsHistoryWaitTimeout(runtime.Int("wait-timeout-ms"))
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
spec := docsHistoryRevertSpec{
Doc: ref,
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
return common.NewDryRunAPI().
Desc("OpenAPI: revert document history").
POST("/open-apis/docs_ai/v1/documents/:document_id/history/revert").
Set("document_id", spec.Doc.Token).
Body(docsHistoryRevertBody(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert")
spec := docsHistoryRevertSpec{
Doc: ref,
HistoryVersionID: strings.TrimSpace(runtime.Str("history-version-id")),
WaitTimeoutMs: runtime.Int("wait-timeout-ms"),
}
data, err := runtime.CallAPITyped(
http.MethodPost,
docsHistoryAPIPath(spec.Doc.Token, "history/revert"),
nil,
docsHistoryRevertBody(spec),
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}
var DocsHistoryRevertStatus = common.Shortcut{
Service: "docs",
Command: "+history-revert-status",
Description: "Get Lark document history revert task status",
Risk: "read",
Scopes: []string{"docx:document:readonly"},
AuthTypes: []string{"user", "bot"},
PostMount: installDocsShortcutHelp("+history-revert-status"),
Flags: []common.Flag{
{Name: "doc", Desc: "document URL or token", Required: true},
{Name: "task-id", Desc: "task_id returned by docs +history-revert", Required: true},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if _, err := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status"); err != nil {
return err
}
if strings.TrimSpace(runtime.Str("task-id")) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--task-id is required").WithParam("--task-id")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
spec := docsHistoryRevertStatusSpec{
Doc: ref,
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
return common.NewDryRunAPI().
Desc("OpenAPI: get document history revert status").
GET("/open-apis/docs_ai/v1/documents/:document_id/history/revert_status").
Set("document_id", spec.Doc.Token).
Params(docsHistoryStatusParams(spec))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
ref, _ := parseDocsHistoryDocRef(runtime.Str("doc"), "+history-revert-status")
spec := docsHistoryRevertStatusSpec{
Doc: ref,
TaskID: strings.TrimSpace(runtime.Str("task-id")),
}
data, err := runtime.CallAPITyped(
http.MethodGet,
docsHistoryAPIPath(spec.Doc.Token, "history/revert_status"),
docsHistoryStatusParams(spec),
nil,
)
if err != nil {
return err
}
runtime.OutRaw(data, nil)
return nil
},
}

View File

@@ -0,0 +1,340 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package doc
import (
"bytes"
"context"
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestDocsHistoryValidation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
shortcut common.Shortcut
args []string
param string
category errs.Category
subtype errs.Subtype
wantCause bool
}{
{
name: "list rejects legacy doc URL",
shortcut: DocsHistoryList,
args: []string{"+history-list", "--doc", "https://example.feishu.cn/doc/old_doc", "--as", "bot"},
param: "--doc",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "list rejects invalid page size",
shortcut: DocsHistoryList,
args: []string{"+history-list", "--doc", "doxcnHistory", "--page-size", "0", "--as", "bot"},
param: "--page-size",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "revert rejects non-numeric history version id",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "abc", "--as", "bot"},
param: "--history-version-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
wantCause: true,
},
{
name: "revert rejects non-positive history version id",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "0", "--as", "bot"},
param: "--history-version-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "revert rejects invalid wait timeout",
shortcut: DocsHistoryRevert,
args: []string{"+history-revert", "--doc", "doxcnHistory", "--history-version-id", "10", "--wait-timeout-ms", "30001", "--as", "bot"},
param: "--wait-timeout-ms",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
{
name: "status rejects empty task id",
shortcut: DocsHistoryRevertStatus,
args: []string{"+history-revert-status", "--doc", "doxcnHistory", "--task-id", "", "--as", "bot"},
param: "--task-id",
category: errs.CategoryValidation,
subtype: errs.SubtypeInvalidArgument,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-validation"))
err := mountAndRunDocs(t, tt.shortcut, tt.args, f, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("error is not typed: %T %v", err, err)
}
if problem.Category != tt.category {
t.Fatalf("category = %q, want %q (err: %v)", problem.Category, tt.category, err)
}
if problem.Subtype != tt.subtype {
t.Fatalf("subtype = %q, want %q (err: %v)", problem.Subtype, tt.subtype, err)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected validation error, got %T: %v", err, err)
}
if validationErr.Param != tt.param {
t.Fatalf("param = %q, want %q (err: %v)", validationErr.Param, tt.param, err)
}
if tt.wantCause && errors.Unwrap(err) == nil {
t.Fatalf("expected wrapped cause, got nil (err: %v)", err)
}
})
}
}
func TestDocsHistoryDryRun(t *testing.T) {
t.Parallel()
listCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryList, map[string]string{
"doc": "doxcnHistoryDryRun",
"page-size": "5",
"page-token": "page_token_1",
})
listDry := decodeDocDryRun(t, DocsHistoryList.DryRun(context.Background(), common.TestNewRuntimeContext(listCmd, nil)))
if got, want := listDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/histories"; got != want {
t.Fatalf("list dry-run URL = %q, want %q", got, want)
}
if got := int(listDry.API[0].Params["page_size"].(float64)); got != 5 {
t.Fatalf("list page_size = %d, want 5", got)
}
if got := listDry.API[0].Params["page_token"]; got != "page_token_1" {
t.Fatalf("list page_token = %#v, want page_token_1", got)
}
revertCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevert, map[string]string{
"doc": "doxcnHistoryDryRun",
"history-version-id": "42",
"wait-timeout-ms": "30000",
})
revertDry := decodeDocDryRun(t, DocsHistoryRevert.DryRun(context.Background(), common.TestNewRuntimeContext(revertCmd, nil)))
if got, want := revertDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert"; got != want {
t.Fatalf("revert dry-run URL = %q, want %q", got, want)
}
if got := revertDry.API[0].Body["history_version_id"]; got != "42" {
t.Fatalf("revert history_version_id = %#v, want 42", got)
}
if got := int(revertDry.API[0].Body["wait_timeout_ms"].(float64)); got != 30000 {
t.Fatalf("revert wait_timeout_ms = %d, want 30000", got)
}
statusCmd := newDocsHistoryRuntimeCmd(t, DocsHistoryRevertStatus, map[string]string{
"doc": "doxcnHistoryDryRun",
"task-id": "task_1",
})
statusDry := decodeDocDryRun(t, DocsHistoryRevertStatus.DryRun(context.Background(), common.TestNewRuntimeContext(statusCmd, nil)))
if got, want := statusDry.API[0].URL, "/open-apis/docs_ai/v1/documents/doxcnHistoryDryRun/history/revert_status"; got != want {
t.Fatalf("status dry-run URL = %q, want %q", got, want)
}
if got := statusDry.API[0].Params["task_id"]; got != "task_1" {
t.Fatalf("status task_id = %#v, want task_1", got)
}
}
func TestDocsHistoryExecuteList(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-list"))
stub := &httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/histories",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"entries": []interface{}{
map[string]interface{}{
"revision_id": float64(42),
"history_version_id": "11",
"edit_time": "1780000000",
"type": float64(1),
"editor_ids": []interface{}{"ou_1"},
},
},
"has_more": true,
"page_token": "page_token_2",
},
},
}
reg.Register(stub)
err := mountAndRunDocs(t, DocsHistoryList, []string{
"+history-list",
"--doc", "doxcnHistory",
"--page-size", "5",
"--page-token", "page_token_1",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["page_token"]; got != "page_token_2" {
t.Fatalf("page_token = %#v, want page_token_2", got)
}
entries, _ := data["entries"].([]interface{})
if len(entries) != 1 {
t.Fatalf("entries = %#v, want one entry", data["entries"])
}
}
func TestDocsHistoryExecuteRevert(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-revert"))
stub := &httpmock.Stub{
Method: "POST",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"task_id": "task_1",
"status": "running",
"history_version_id": "42",
"poll_after_ms": float64(10000),
},
},
}
reg.Register(stub)
err := mountAndRunDocs(t, DocsHistoryRevert, []string{
"+history-revert",
"--doc", "doxcnHistory",
"--history-version-id", "42",
"--wait-timeout-ms", "0",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("decode revert body: %v\nraw=%s", err, stub.CapturedBody)
}
if got := body["history_version_id"]; got != "42" {
t.Fatalf("history_version_id = %#v, want 42", got)
}
if got := int(body["wait_timeout_ms"].(float64)); got != 0 {
t.Fatalf("wait_timeout_ms = %d, want 0", got)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["task_id"]; got != "task_1" {
t.Fatalf("task_id = %#v, want task_1", got)
}
}
func TestDocsHistoryExecuteRevertStatus(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, docsTestConfigWithAppID("docs-history-status"))
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/docs_ai/v1/documents/doxcnHistory/history/revert_status",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"status": "partial_failed",
"history_version_id": "11",
"failed_block_tokens": []interface{}{"blk_1"},
},
},
})
err := mountAndRunDocs(t, DocsHistoryRevertStatus, []string{
"+history-revert-status",
"--doc", "doxcnHistory",
"--task-id", "task_1",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeDocsHistoryEnvelope(t, stdout)
if got := data["status"]; got != "partial_failed" {
t.Fatalf("status = %#v, want partial_failed", got)
}
if got := data["history_version_id"]; got != "11" {
t.Fatalf("history_version_id = %#v, want 11", got)
}
failed, _ := data["failed_block_tokens"].([]interface{})
if len(failed) != 1 || failed[0] != "blk_1" {
t.Fatalf("failed_block_tokens = %#v, want [blk_1]", data["failed_block_tokens"])
}
}
func newDocsHistoryRuntimeCmd(t *testing.T, shortcut common.Shortcut, values map[string]string) *cobra.Command {
t.Helper()
cmd := &cobra.Command{Use: shortcut.Command}
for _, flag := range shortcut.Flags {
switch flag.Type {
case "int":
cmd.Flags().Int(flag.Name, 0, flag.Desc)
default:
cmd.Flags().String(flag.Name, flag.Default, flag.Desc)
}
}
for name, value := range values {
if err := cmd.Flags().Set(name, value); err != nil {
t.Fatalf("set --%s: %v", name, err)
}
}
return cmd
}
func decodeDocsHistoryEnvelope(t *testing.T, stdout *bytes.Buffer) map[string]interface{} {
t.Helper()
var envelope map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("decode envelope: %v\nraw=%s", err, stdout.String())
}
data, _ := envelope["data"].(map[string]interface{})
if data == nil {
t.Fatalf("missing data in envelope: %#v", envelope)
}
return data
}
func TestDocsHistoryURLValidationMessage(t *testing.T) {
t.Parallel()
_, err := parseDocsHistoryDocRef("https://example.feishu.cn/doc/old_doc", "+history-list")
if err == nil {
t.Fatal("expected error")
}
if !strings.Contains(err.Error(), "only supports docx documents") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@@ -31,6 +31,8 @@ func docsSkillReadCommandForShortcut(shortcut string) string {
return docsSkillReadCommand + " references/lark-doc-fetch.md" return docsSkillReadCommand + " references/lark-doc-fetch.md"
case "update": case "update":
return docsSkillReadCommand + " references/lark-doc-update.md" return docsSkillReadCommand + " references/lark-doc-update.md"
case "history-list", "history-revert", "history-revert-status":
return docsSkillReadCommand + " references/lark-doc-history.md"
default: default:
return docsSkillReadCommand return docsSkillReadCommand
} }
@@ -44,6 +46,12 @@ func docsHelpCommandForShortcut(shortcut string) string {
return "lark-cli docs +fetch --help" return "lark-cli docs +fetch --help"
case "update": case "update":
return "lark-cli docs +update --help" return "lark-cli docs +update --help"
case "history-list":
return "lark-cli docs +history-list --help"
case "history-revert":
return "lark-cli docs +history-revert --help"
case "history-revert-status":
return "lark-cli docs +history-revert-status --help"
default: default:
return "lark-cli docs --help" return "lark-cli docs --help"
} }
@@ -56,6 +64,9 @@ func Shortcuts() []common.Shortcut {
DocsCreate, DocsCreate,
DocsFetch, DocsFetch,
DocsUpdate, DocsUpdate,
DocsHistoryList,
DocsHistoryRevert,
DocsHistoryRevertStatus,
DocMediaInsert, DocMediaInsert,
DocMediaUpload, DocMediaUpload,
DocMediaPreview, DocMediaPreview,

View File

@@ -8,6 +8,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"time" "time"
@@ -28,6 +29,8 @@ const (
driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024 driveImport500MBFileSizeLimit int64 = 500 * 1024 * 1024
driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024 driveImport600MBFileSizeLimit int64 = 600 * 1024 * 1024
driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024 driveImport800MBFileSizeLimit int64 = 800 * 1024 * 1024
driveImportConcurrentOperationHint = "This import conflict means another operation is running in the same Drive location. Run batch imports to the same folder/root or target bitable serially. Wait a few seconds before retrying each failed import; retry each failed item at most 3 times, then stop and report the conflict."
) )
// driveImportExtToDocTypes defines which source file extensions can be imported // driveImportExtToDocTypes defines which source file extensions can be imported
@@ -47,6 +50,8 @@ var driveImportExtToDocTypes = map[string][]string{
"pptx": {"slides"}, "pptx": {"slides"},
} }
var driveImportConcurrentOperationCodes = []int{232140101, 232140100, 233523001}
// driveImportSpec contains the user-facing import inputs after normalization. // driveImportSpec contains the user-facing import inputs after normalization.
type driveImportSpec struct { type driveImportSpec struct {
FilePath string FilePath string
@@ -427,11 +432,7 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
return status, true, nil return status, true, nil
} }
if status.Failed() { if status.Failed() {
msg := strings.TrimSpace(status.JobErrorMsg) return status, false, driveImportFailureError(status)
if msg == "" {
msg = status.StatusLabel()
}
return status, false, errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
} }
} }
if !hadSuccessfulPoll && lastErr != nil { if !hadSuccessfulPoll && lastErr != nil {
@@ -440,3 +441,40 @@ func pollDriveImportTask(runtime *common.RuntimeContext, ticket string) (driveIm
return lastStatus, false, nil return lastStatus, false, nil
} }
func driveImportFailureError(status driveImportStatus) *errs.APIError {
msg := strings.TrimSpace(status.JobErrorMsg)
if msg == "" {
msg = status.StatusLabel()
}
apiErr := errs.NewAPIError(errs.SubtypeServerError, "import failed with status %d: %s", status.JobStatus, msg)
if code, ok := driveImportConcurrentOperationCode(msg); ok {
apiErr = apiErr.WithCode(code).WithRetryable().WithHint(driveImportConcurrentOperationHint)
}
return apiErr
}
func driveImportConcurrentOperationCode(msg string) (int, bool) {
for _, code := range driveImportConcurrentOperationCodes {
codeText := strconv.Itoa(code)
for idx := strings.Index(msg, codeText); idx >= 0; {
end := idx + len(codeText)
if (idx == 0 || !isASCIIDigit(msg[idx-1])) && (end == len(msg) || !isASCIIDigit(msg[end])) {
return code, true
}
nextStart := idx + 1
next := strings.Index(msg[nextStart:], codeText)
if next < 0 {
break
}
idx = nextStart + next
}
}
return 0, false
}
func isASCIIDigit(ch byte) bool {
return ch >= '0' && ch <= '9'
}

View File

@@ -7,6 +7,7 @@ import (
"bytes" "bytes"
"errors" "errors"
"os" "os"
"strconv"
"strings" "strings"
"testing" "testing"
@@ -211,6 +212,82 @@ func TestDriveImportStatusPendingWithoutToken(t *testing.T) {
} }
} }
func TestDriveImportFailureErrorAddsConcurrentOperationGuidance(t *testing.T) {
t.Parallel()
for _, code := range driveImportConcurrentOperationCodes {
t.Run(strconv.Itoa(code), func(t *testing.T) {
t.Parallel()
err := driveImportFailureError(driveImportStatus{
JobStatus: 3,
JobErrorMsg: "call CreateObjNode return error code, code: " + strconv.Itoa(code) + ", message:",
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Category != errs.CategoryAPI {
t.Fatalf("category = %q, want %q", problem.Category, errs.CategoryAPI)
}
if problem.Subtype != errs.SubtypeServerError {
t.Fatalf("subtype = %q, want %q", problem.Subtype, errs.SubtypeServerError)
}
if problem.Code != code {
t.Fatalf("code = %d, want %d", problem.Code, code)
}
if !problem.Retryable {
t.Fatal("expected retryable error")
}
if problem.Hint != driveImportConcurrentOperationHint {
t.Fatalf("hint = %q, want %q", problem.Hint, driveImportConcurrentOperationHint)
}
})
}
}
func TestDriveImportFailureErrorLeavesOtherFailuresUnchanged(t *testing.T) {
t.Parallel()
tests := []struct {
name string
msg string
}{
{
name: "ordinary failure",
msg: "unsupported conversion",
},
{
name: "longer numeric code containing known code",
msg: "call CreateObjNode return error code, code: 12321401012, message:",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
err := driveImportFailureError(driveImportStatus{
JobStatus: 3,
JobErrorMsg: tt.msg,
})
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got %T", err)
}
if problem.Code != 0 {
t.Fatalf("code = %d, want 0", problem.Code)
}
if problem.Retryable {
t.Fatal("expected non-concurrency failure to remain non-retryable")
}
if problem.Hint != "" {
t.Fatalf("hint = %q, want empty", problem.Hint)
}
})
}
}
func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) { func TestDriveImportTimeoutReturnsFollowUpCommand(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{

View File

@@ -184,6 +184,7 @@ var DrivePull = common.Shortcut{
var downloaded, skipped, failed, deletedLocal int var downloaded, skipped, failed, deletedLocal int
downloadFailed := 0 downloadFailed := 0
aborted := false
items := make([]drivePullItem, 0) items := make([]drivePullItem, 0)
// Deterministic iteration order for output stability. // Deterministic iteration order for output stability.
@@ -194,7 +195,7 @@ var DrivePull = common.Shortcut{
sort.Strings(downloadablePaths) sort.Strings(downloadablePaths)
for _, rel := range downloadablePaths { for _, rel := range downloadablePaths {
if drivePullHasTerminalFailure(items) { if aborted {
break break
} }
targetFile := remoteFiles[rel] targetFile := remoteFiles[rel]
@@ -232,6 +233,7 @@ var DrivePull = common.Shortcut{
failed++ failed++
downloadFailed++ downloadFailed++
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +pull after terminal %s failure: %v\n", item.Phase, err)
break break
} }
@@ -298,7 +300,7 @@ var DrivePull = common.Shortcut{
"skipped": skipped, "skipped": skipped,
"failed": failed, "failed": failed,
"deleted_local": deletedLocal, "deleted_local": deletedLocal,
"aborted": drivePullHasTerminalFailure(items), "aborted": aborted,
}, },
"items": items, "items": items,
} }
@@ -347,15 +349,6 @@ func drivePullFailedItem(relPath, fileToken, sourceID, action, phase string, err
return item, decision.Terminal return item, decision.Terminal
} }
func drivePullHasTerminalFailure(items []drivePullItem) bool {
for _, item := range items {
if driveTerminalBatchErrorClass(item.ErrorClass) {
return true
}
}
return false
}
// drivePullDownload streams one Drive file into the local mirror target and // drivePullDownload streams one Drive file into the local mirror target and
// then best-effort aligns the local mtime to Drive's modified_time. // then best-effort aligns the local mtime to Drive's modified_time.
func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error { func drivePullDownload(ctx context.Context, runtime *common.RuntimeContext, fileToken, target, remoteModifiedTime string) error {

View File

@@ -35,6 +35,7 @@ type drivePushItem struct {
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
SizeBytes int64 `json:"size_bytes,omitempty"` SizeBytes int64 `json:"size_bytes,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Hint string `json:"hint,omitempty"`
Phase string `json:"phase,omitempty"` Phase string `json:"phase,omitempty"`
ErrorClass string `json:"error_class,omitempty"` ErrorClass string `json:"error_class,omitempty"`
Code int `json:"code,omitempty"` Code int `json:"code,omitempty"`
@@ -48,6 +49,7 @@ type driveBatchFailureDecision struct {
Subtype string Subtype string
Retryable bool Retryable bool
Terminal bool Terminal bool
Hint string
} }
// DrivePush is a one-way, file-level mirror from a local directory onto a // DrivePush is a one-way, file-level mirror from a local directory onto a
@@ -240,6 +242,7 @@ var DrivePush = common.Shortcut{
// locally and now on Drive too), which is the worst-of-both-worlds // locally and now on Drive too), which is the worst-of-both-worlds
// outcome the review flagged. // outcome the review flagged.
uploadFailed := false uploadFailed := false
aborted := false
// folderCache holds rel_path → folder_token. Seeded from the remote // folderCache holds rel_path → folder_token. Seeded from the remote
// listing (so we don't recreate folders that already exist) and // listing (so we don't recreate folders that already exist) and
@@ -266,6 +269,7 @@ var DrivePush = common.Shortcut{
failed++ failed++
uploadFailed = true uploadFailed = true
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
break break
} }
@@ -284,7 +288,7 @@ var DrivePush = common.Shortcut{
for _, rel := range localPaths { for _, rel := range localPaths {
localFile := localFiles[rel] localFile := localFiles[rel]
if uploadFailed && drivePushHasTerminalFailure(items) { if uploadFailed && aborted {
break break
} }
@@ -301,6 +305,7 @@ var DrivePush = common.Shortcut{
failed++ failed++
uploadFailed = true uploadFailed = true
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, parentErr)
break break
} }
@@ -332,6 +337,7 @@ var DrivePush = common.Shortcut{
failed++ failed++
uploadFailed = true uploadFailed = true
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
break break
} }
@@ -350,6 +356,7 @@ var DrivePush = common.Shortcut{
failed++ failed++
uploadFailed = true uploadFailed = true
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, ensureErr)
break break
} }
@@ -362,6 +369,7 @@ var DrivePush = common.Shortcut{
failed++ failed++
uploadFailed = true uploadFailed = true
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, upErr)
break break
} }
@@ -407,10 +415,15 @@ var DrivePush = common.Shortcut{
continue continue
} }
if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil { if err := drivePushDeleteFile(ctx, runtime, entry.FileToken); err != nil {
if drivePushIsAlreadyDeleted(err) {
items = append(items, drivePushItem{RelPath: rel, FileToken: entry.FileToken, Action: "already_deleted"})
continue
}
item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err) item, terminal := drivePushFailedItem(rel, entry.FileToken, "delete_failed", "delete", 0, err)
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +push after terminal %s failure: %v\n", item.Phase, err)
abortDelete = true abortDelete = true
break break
@@ -429,7 +442,7 @@ var DrivePush = common.Shortcut{
"skipped": skipped, "skipped": skipped,
"failed": failed, "failed": failed,
"deleted_remote": deletedRemote, "deleted_remote": deletedRemote,
"aborted": drivePushHasTerminalFailure(items), "aborted": aborted,
}, },
"items": items, "items": items,
} }
@@ -567,6 +580,7 @@ func drivePushFailedItem(relPath, fileToken, action, phase string, sizeBytes int
Action: action, Action: action,
SizeBytes: sizeBytes, SizeBytes: sizeBytes,
Error: err.Error(), Error: err.Error(),
Hint: decision.Hint,
Phase: phase, Phase: phase,
ErrorClass: decision.Class, ErrorClass: decision.Class,
Code: decision.Code, Code: decision.Code,
@@ -613,6 +627,10 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
decision.Class = "file_size_limit" decision.Class = "file_size_limit"
case problem.Code == 1062009: case problem.Code == 1062009:
decision.Class = "upload_size_mismatch" decision.Class = "upload_size_mismatch"
case problem.Code == 1061044:
decision.Class = "parent_node_missing"
decision.Terminal = true
decision.Hint = "The destination parent folder no longer exists or is not visible. Verify --folder-token, folder permissions, and whether a parent directory was deleted during push before retrying."
case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007: case problem.Subtype == errs.SubtypeNotFound || problem.Code == 1061007:
decision.Class = "remote_not_found" decision.Class = "remote_not_found"
case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200: case problem.Subtype == errs.SubtypeServerError || problem.Code == 1061001 || problem.Code == 2200:
@@ -626,22 +644,9 @@ func driveClassifyBatchFailure(err error) driveBatchFailureDecision {
return decision return decision
} }
func drivePushHasTerminalFailure(items []drivePushItem) bool { func drivePushIsAlreadyDeleted(err error) bool {
for _, item := range items { problem, ok := errs.ProblemOf(err)
if driveTerminalBatchErrorClass(item.ErrorClass) { return ok && problem.Code == 1061007
return true
}
}
return false
}
func driveTerminalBatchErrorClass(errorClass string) bool {
switch errorClass {
case "app_scope_missing", "user_scope_missing", "permission_denied", "invalid_api_parameters", "rate_limited", "server_error":
return true
default:
return false
}
} }
func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) { func drivePushRemoteViews(entries []driveRemoteEntry, duplicateRemote string) (map[string]driveRemoteEntry, map[string]driveRemoteEntry, map[string][]driveRemoteEntry, error) {

View File

@@ -732,6 +732,65 @@ func TestDrivePushDeleteRemoteAbortsAfterTerminalFailure(t *testing.T) {
} }
} }
func TestDrivePushDeleteRemoteTreatsAlreadyDeletedAsNoop(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{
"files": []interface{}{
map[string]interface{}{"token": "tok_orphan", "name": "orphan.txt", "type": "file"},
},
"has_more": false,
},
},
})
reg.Register(&httpmock.Stub{
Method: "DELETE",
URL: "/open-apis/drive/v1/files/tok_orphan",
Body: map[string]interface{}{
"code": 1061007,
"msg": "file has been delete.",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--delete-remote",
"--yes",
"--as", "bot",
}, f, stdout)
if err != nil {
t.Fatalf("already-deleted remote should be an idempotent success, got: %v\nstdout: %s", err, stdout.String())
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(0) {
t.Fatalf("summary.failed = %v, want 0", got)
}
if got := summary["deleted_remote"]; got != float64(0) {
t.Fatalf("summary.deleted_remote = %v, want 0 because CLI did not delete it in this run", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["action"] != "already_deleted" || item["file_token"] != "tok_orphan" {
t.Fatalf("unexpected already-deleted item: %#v", item)
}
}
func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) { func TestDrivePushNewestOverwritesChosenDuplicateAndDeletesSibling(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
@@ -1137,6 +1196,78 @@ func TestDrivePushAbortsAfterUploadParamsError(t *testing.T) {
} }
} }
func TestDrivePushAbortsAfterUploadParentNodeMissing(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())
tmpDir := t.TempDir()
withDriveWorkingDir(t, tmpDir)
if err := os.MkdirAll("local", 0o755); err != nil {
t.Fatalf("MkdirAll: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "a.txt"), []byte("A"), 0o644); err != nil {
t.Fatalf("WriteFile a: %v", err)
}
if err := os.WriteFile(filepath.Join("local", "b.txt"), []byte("B"), 0o644); err != nil {
t.Fatalf("WriteFile b: %v", err)
}
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "folder_token=folder_root",
Body: map[string]interface{}{
"code": 0, "msg": "ok",
"data": map[string]interface{}{"files": []interface{}{}, "has_more": false},
},
})
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/drive/v1/files/upload_all",
Body: map[string]interface{}{
"code": 1061044,
"msg": "parent node not exist.",
},
})
err := mountAndRunDrive(t, DrivePush, []string{
"+push",
"--local-dir", "local",
"--folder-token", "folder_root",
"--as", "bot",
}, f, stdout)
if err == nil {
t.Fatalf("expected partial failure, got nil\nstdout: %s", stdout.String())
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
summary, items := splitDrivePushStdout(t, stdout.Bytes())
if got := summary["failed"]; got != float64(1) {
t.Fatalf("summary.failed = %v, want 1", got)
}
if got := summary["aborted"]; got != true {
t.Fatalf("summary.aborted = %v, want true", got)
}
if len(items) != 1 {
t.Fatalf("items len = %d, want 1; items=%#v", len(items), items)
}
item := items[0]
if item["rel_path"] != "a.txt" || item["phase"] != "upload" || item["error_class"] != "parent_node_missing" {
t.Fatalf("unexpected failed item: %#v", item)
}
if item["code"] != float64(1061044) || item["subtype"] != "not_found" || item["retryable"] != false {
t.Fatalf("unexpected failure metadata: %#v", item)
}
if got, _ := item["hint"].(string); !strings.Contains(got, "--folder-token") || !strings.Contains(got, "parent") {
t.Fatalf("hint should point at the destination parent folder, got item=%#v", item)
}
for _, item := range items {
if item["rel_path"] == "b.txt" {
t.Fatalf("parent-node missing must abort before b.txt, got items=%#v", items)
}
}
}
func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) { func TestDrivePushAbortsAfterCreateFolderMissingScope(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig()) f, stdout, _, reg := cmdutil.TestFactory(t, driveTestConfig())

View File

@@ -75,7 +75,7 @@ var DriveSearch = common.Shortcut{
AuthTypes: []string{"user", "bot"}, AuthTypes: []string{"user", "bot"},
HasFormat: true, HasFormat: true,
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "query", Desc: "search keyword (may be empty to browse by filter only)"}, {Name: "query", Desc: "search keyword (may be empty to browse by filter only); max 30 characters by Unicode code point (CJK counts 1 each), over 30 the server rejects with 99992402 field validation failed"},
{Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"}, {Name: "mine", Type: "bool", Desc: "restrict to docs I own (server-side owner semantic, NOT original creator; uses current user's open_id)"},
{Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"}, {Name: "creator-ids", Desc: "comma-separated owner open_ids (API field is creator_ids but matched by owner); mutually exclusive with --mine"},

View File

@@ -268,6 +268,7 @@ var DriveSync = common.Shortcut{
// --- Phase 2: Execute sync operations --- // --- Phase 2: Execute sync operations ---
var pulled, pushed, skipped, failed int var pulled, pushed, skipped, failed int
aborted := false
items := make([]driveSyncItem, 0) items := make([]driveSyncItem, 0)
// Build push infrastructure: local walk for push + remote views + folder cache. // Build push infrastructure: local walk for push + remote views + folder cache.
@@ -286,16 +287,21 @@ var DriveSync = common.Shortcut{
// Mirror local directory structure first (same as +push), so // Mirror local directory structure first (same as +push), so
// empty local directories are not silently dropped. // empty local directories are not silently dropped.
for _, relDir := range localDirs { for _, relDir := range localDirs {
if driveSyncHasTerminalFailure(items) { if aborted {
break break
} }
if _, alreadyRemote := folderCache[relDir]; alreadyRemote { if _, alreadyRemote := folderCache[relDir]; alreadyRemote {
continue continue
} }
if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil { if _, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, relDir, folderCache); ensureErr != nil {
item, _ := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr) item, terminal := driveSyncFailedItem(relDir, "", "failed", "push", "create_folder", ensureErr)
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
break
}
continue continue
} }
items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"}) items = append(items, driveSyncItem{RelPath: relDir, FileToken: folderCache[relDir], Action: "folder_created", Direction: "push"})
@@ -304,7 +310,7 @@ var DriveSync = common.Shortcut{
// 2a. Pull new_remote files. // 2a. Pull new_remote files.
for _, entry := range newRemote { for _, entry := range newRemote {
if driveSyncHasTerminalFailure(items) { if aborted {
break break
} }
targetFile, ok := pullRemoteFiles[entry.RelPath] targetFile, ok := pullRemoteFiles[entry.RelPath]
@@ -318,6 +324,7 @@ var DriveSync = common.Shortcut{
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
break break
} }
@@ -329,7 +336,7 @@ var DriveSync = common.Shortcut{
// 2b. Push new_local files. // 2b. Push new_local files.
for _, entry := range newLocal { for _, entry := range newLocal {
if driveSyncHasTerminalFailure(items) { if aborted {
break break
} }
localFile, ok := pushLocalFiles[entry.RelPath] localFile, ok := pushLocalFiles[entry.RelPath]
@@ -341,9 +348,14 @@ var DriveSync = common.Shortcut{
parentRel := drivePushParentRel(entry.RelPath) parentRel := drivePushParentRel(entry.RelPath)
parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache) parentToken, ensureErr := drivePushEnsureFolder(ctx, runtime, folderToken, parentRel, folderCache)
if ensureErr != nil { if ensureErr != nil {
item, _ := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr) item, terminal := driveSyncFailedItem(entry.RelPath, "", "failed", "push", "create_folder", ensureErr)
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, ensureErr)
break
}
continue continue
} }
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken) token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, "", parentToken)
@@ -352,6 +364,7 @@ var DriveSync = common.Shortcut{
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
break break
} }
@@ -363,7 +376,7 @@ var DriveSync = common.Shortcut{
// 2c. Resolve modified files by --on-conflict strategy. // 2c. Resolve modified files by --on-conflict strategy.
for _, entry := range modified { for _, entry := range modified {
if driveSyncHasTerminalFailure(items) { if aborted {
break break
} }
remoteFile := remoteFiles[entry.RelPath] remoteFile := remoteFiles[entry.RelPath]
@@ -397,6 +410,7 @@ var DriveSync = common.Shortcut{
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, err)
break break
} }
@@ -415,9 +429,14 @@ var DriveSync = common.Shortcut{
} }
parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache) parentToken, parentErr := drivePushEnsureFolder(ctx, runtime, folderToken, drivePushParentRel(entry.RelPath), folderCache)
if parentErr != nil { if parentErr != nil {
item, _ := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr) item, terminal := driveSyncFailedItem(entry.RelPath, existingToken, "failed", "push", "create_folder", parentErr)
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, parentErr)
break
}
continue continue
} }
token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken) token, _, upErr := drivePushUploadFile(ctx, runtime, localFile, existingToken, parentToken)
@@ -435,6 +454,7 @@ var DriveSync = common.Shortcut{
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, upErr)
break break
} }
@@ -503,6 +523,7 @@ var DriveSync = common.Shortcut{
items = append(items, item) items = append(items, item)
failed++ failed++
if terminal { if terminal {
aborted = true
fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr) fmt.Fprintf(runtime.IO().ErrOut, "Aborting +sync after terminal %s failure: %v\n", item.Phase, downloadErr)
break break
} }
@@ -531,7 +552,7 @@ var DriveSync = common.Shortcut{
"pushed": pushed, "pushed": pushed,
"skipped": skipped, "skipped": skipped,
"failed": failed, "failed": failed,
"aborted": driveSyncHasTerminalFailure(items), "aborted": aborted,
}, },
"items": items, "items": items,
} }
@@ -577,15 +598,6 @@ func driveSyncFailedItem(relPath, fileToken, action, direction, phase string, er
return item, decision.Terminal return item, decision.Terminal
} }
func driveSyncHasTerminalFailure(items []driveSyncItem) bool {
for _, item := range items {
if driveTerminalBatchErrorClass(item.ErrorClass) {
return true
}
}
return false
}
// driveSyncAskConflict prompts the user for a conflict resolution strategy // driveSyncAskConflict prompts the user for a conflict resolution strategy
// for a single file. Returns the strategy string, or empty string if the // for a single file. Returns the strategy string, or empty string if the
// user chose to skip. // user chose to skip.

View File

@@ -651,6 +651,7 @@ func TestShortcuts(t *testing.T) {
want := []string{ want := []string{
"+chat-create", "+chat-create",
"+chat-list", "+chat-list",
"+chat-members-list",
"+chat-messages-list", "+chat-messages-list",
"+chat-search", "+chat-search",
"+chat-update", "+chat-update",

View File

@@ -0,0 +1,420 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
const (
imChatMembersListPathFmt = "/open-apis/im/v1/chats/%s/members/list"
chatMembersListDefaultPageSize = 20
chatMembersListMaxPageSize = 100
// chatMembersListDefaultPageDelay throttles --page-all the same way the
// generic paginateLoop does (200ms). It matters for tenants WITHOUT the
// server-side member cap, where a large group drains many pages back to
// back and could otherwise trip rate limits.
chatMembersListDefaultPageDelay = 200
)
// ImChatMembersList is the +chat-members-list shortcut: it lists chat members,
// returning users and bots in separate buckets (users[]/bots[]). It owns its
// pagination loop (mirroring the generic paginateLoop conventions: a per-page
// log line, a --page-limit cap, a non-advancing-token guard) precisely because
// the response is multi-bucket — the generic --page-all merger is built for
// single-array responses and would drop the bots[] bucket and the final-page
// truncations[] signal. See mergeChatMemberPages for the merge semantics.
var ImChatMembersList = common.Shortcut{
Service: "im",
Command: "+chat-members-list",
Description: "List members of a chat; returns separate users[] / bots[] buckets; callable as user or bot; --member-types filters which kinds to return; --page-all pagination; surfaces truncations[] when the server caps a bucket",
Risk: "read",
// Declare the narrowest scope the API accepts so tokens carrying only
// im:chat.members:read are honored (same rationale as +chat-list).
Scopes: []string{"im:chat.members:read"},
AuthTypes: []string{"user", "bot"},
Flags: []common.Flag{
{Name: "chat-id", Required: true, Desc: "chat ID (oc_xxx)"},
{Name: "member-types", Type: "string_slice", Desc: "member types to return (user, bot); omit = all"},
{Name: "member-id-type", Default: "open_id", Desc: "ID type for member_id in response", Enum: []string{"open_id", "union_id", "user_id"}},
{Name: "page-size", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageSize), Desc: fmt.Sprintf("page size, 1-%d", chatMembersListMaxPageSize)},
{Name: "page-token", Desc: "page token; implies single-page fetch (no auto-pagination)"},
{Name: "page-all", Type: "bool", Desc: "automatically paginate through all pages (capped by --page-limit)"},
{Name: "page-limit", Type: "int", Default: "10", Desc: "max pages to fetch with --page-all (default 10, 0 = unlimited)"},
{Name: "page-delay", Type: "int", Default: fmt.Sprintf("%d", chatMembersListDefaultPageDelay), Desc: "delay in ms between pages when --page-all (0 = no delay)"},
},
Tips: []string{
"Default fetches a single page; pass --page-all to walk every page.",
"With --page-all and no explicit --page-size, the max page size is used to minimize round-trips.",
"truncations[] in the result means the server capped a bucket due to security config — the member list is incomplete.",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
chatID := strings.TrimSpace(runtime.Str("chat-id"))
if chatID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--chat-id is required (oc_xxx)").WithParam("--chat-id")
}
if !strings.HasPrefix(chatID, "oc_") {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --chat-id %q: must be an open_chat_id starting with oc_", chatID).WithParam("--chat-id")
}
if n := runtime.Int("page-size"); n < 1 || n > chatMembersListMaxPageSize {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-size must be an integer between 1 and %d", chatMembersListMaxPageSize).WithParam("--page-size")
}
if n := runtime.Int("page-limit"); n < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-limit must be a non-negative integer").WithParam("--page-limit")
}
if n := runtime.Int("page-delay"); n < 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--page-delay must be a non-negative integer").WithParam("--page-delay")
}
_, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
return err
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
chatID := strings.TrimSpace(runtime.Str("chat-id"))
dry := common.NewDryRunAPI()
if chatMembersShouldAutoPaginate(runtime) {
dry.Desc("Auto-paginates through all pages (capped by --page-limit when > 0)")
}
params, _ := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
return dry.
GET(fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))).
Params(params)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
warnIfConflictingPagingFlags(runtime)
chatID := strings.TrimSpace(runtime.Str("chat-id"))
res, err := fetchChatMembers(ctx, runtime, chatID)
if err != nil {
return err
}
// The truncation signal is the whole reason this is a dedicated shortcut:
// surface it loudly so an agent never mistakes a capped list for a
// complete one.
if len(res.truncations) > 0 {
writeChatMembersTruncationWarning(runtime.IO().ErrOut, res.truncations)
}
fmt.Fprintf(runtime.IO().ErrOut, "Found %d user(s) and %d bot(s)\n", len(res.users), len(res.bots))
outData := map[string]interface{}{
"chat_id": chatID,
"users": res.users,
"bots": res.bots,
"truncations": res.truncations,
"has_more": res.hasMore,
"page_token": res.pageToken,
}
if res.userTotal != nil {
outData["user_total"] = res.userTotal
}
if res.botTotal != nil {
outData["bot_total"] = res.botTotal
}
runtime.OutFormat(outData, &output.Meta{Count: len(res.users) + len(res.bots)}, func(w io.Writer) {
renderChatMembersPretty(w, chatID, res)
})
return nil
},
}
// chatMembersResult is the aggregated view across one or more pages.
type chatMembersResult struct {
users []interface{}
bots []interface{}
truncations []interface{}
userTotal interface{}
botTotal interface{}
hasMore bool
pageToken string
}
// effectiveChatMembersPageSize resolves the page_size to request. When draining
// every page (--page-all) and the caller did NOT explicitly set --page-size, it
// uses the maximum so a full walk takes the fewest round-trips. An explicit
// --page-size is always honored; without --page-all the smaller default is kept
// as a sensible single-page preview size.
func effectiveChatMembersPageSize(runtime *common.RuntimeContext) int {
if chatMembersShouldAutoPaginate(runtime) && !runtime.Changed("page-size") {
return chatMembersListMaxPageSize
}
if n := runtime.Int("page-size"); n > 0 {
return n
}
return chatMembersListDefaultPageSize
}
// chatMembersShouldAutoPaginate reports whether the fetch loop should walk
// every page. An explicit --page-token disables the auto loop because the
// caller supplied a specific cursor (single-page fetch).
func chatMembersShouldAutoPaginate(runtime *common.RuntimeContext) bool {
if strings.TrimSpace(runtime.Str("page-token")) != "" {
return false
}
return runtime.Bool("page-all")
}
// buildChatMembersParams builds the query params for one page request. The
// startToken (when non-empty) seeds the page_token; the loop overrides it per
// page. Returns the params and the normalized member-types CSV (already
// validated by Validate, so the error is only a defensive guard).
func buildChatMembersParams(runtime *common.RuntimeContext, startToken string) (map[string]interface{}, error) {
memberTypes, err := normalizeMemberTypes(runtime.StrSlice("member-types"))
if err != nil {
return nil, err
}
params := map[string]interface{}{
"member_id_type": runtime.Str("member-id-type"),
"page_size": effectiveChatMembersPageSize(runtime),
}
if memberTypes != "" {
params["member_types"] = memberTypes
}
if startToken != "" {
params["page_token"] = startToken
}
return params, nil
}
// fetchChatMembers walks the list_members endpoint, honoring the four
// pagination flags the same way the generic --page-all path does. It merges
// each page into the aggregate as it arrives (rather than buffering every raw
// page), so peak memory is just the aggregated members plus the single most
// recent page — important for large groups under --page-limit 0.
func fetchChatMembers(ctx context.Context, runtime *common.RuntimeContext, chatID string) (*chatMembersResult, error) {
auto := chatMembersShouldAutoPaginate(runtime)
pageLimit := runtime.Int("page-limit")
pageDelay := runtime.Int("page-delay")
apiPath := fmt.Sprintf(imChatMembersListPathFmt, validate.EncodePathSegment(chatID))
params, err := buildChatMembersParams(runtime, strings.TrimSpace(runtime.Str("page-token")))
if err != nil {
return nil, err
}
res := newChatMembersResult()
var lastData map[string]interface{}
pageToken := strings.TrimSpace(runtime.Str("page-token"))
for page := 0; ; page++ {
if pageToken != "" {
params["page_token"] = pageToken
}
fmt.Fprintf(runtime.IO().ErrOut, "[page %d] fetching...\n", page+1)
data, err := runtime.CallAPITyped("GET", apiPath, params, nil)
if err != nil {
return nil, err
}
addMemberBuckets(res, data)
lastData = data
hasMore, nextToken := common.PaginationMeta(data)
if !auto {
break
}
if !hasMore || nextToken == "" {
break
}
if nextToken == pageToken {
// Guard against a buggy server echoing the same cursor with
// has_more=true: without --page-limit we would loop forever.
fmt.Fprintln(runtime.IO().ErrOut, "Stopping pagination: server returned a non-advancing page_token.")
break
}
if pageLimit > 0 && page+1 >= pageLimit {
fmt.Fprintf(runtime.IO().ErrOut, "[pagination] reached page limit (%d), stopping. Use --page-all --page-limit 0 to fetch all pages.\n", pageLimit)
break
}
pageToken = nextToken
// Throttle between pages (only reached when another page follows), so
// draining a large untruncated list doesn't hammer the API.
if pageDelay > 0 {
time.Sleep(time.Duration(pageDelay) * time.Millisecond)
}
}
if lastData != nil {
applyLastPageSignals(res, lastData)
}
return res, nil
}
// newChatMembersResult returns an empty aggregate with non-nil buckets so the
// JSON output always carries arrays (never null).
func newChatMembersResult() *chatMembersResult {
return &chatMembersResult{
users: []interface{}{},
bots: []interface{}{},
truncations: []interface{}{},
}
}
// addMemberBuckets appends one page's users[] and bots[] into the aggregate.
// Concatenating every bucket is what avoids dropping bots[] — the bug the
// generic single-array --page-all merger would hit on this multi-bucket shape.
func addMemberBuckets(res *chatMembersResult, data map[string]interface{}) {
if u, ok := data["users"].([]interface{}); ok {
res.users = append(res.users, u...)
}
if b, ok := data["bots"].([]interface{}); ok {
res.bots = append(res.bots, b...)
}
}
// applyLastPageSignals copies the per-request signals from the FINAL page:
// has_more / page_token / truncations / totals. These must come from the last
// page, not page 1: truncations[] is emitted only on the final page (empty
// earlier), so reading it sooner would hide a server-side cap; user_total /
// bot_total are server-wide counts, and taking the final page's value keeps a
// single, consistent source rather than a possibly-stale earlier count.
func applyLastPageSignals(res *chatMembersResult, data map[string]interface{}) {
res.hasMore, res.pageToken = common.PaginationMeta(data)
if t, ok := data["truncations"].([]interface{}); ok {
res.truncations = t
}
res.userTotal = data["user_total"]
res.botTotal = data["bot_total"]
}
// mergeChatMemberPages folds a slice of page payloads into one aggregate. It is
// the same logic fetchChatMembers applies incrementally, kept as a pure
// function so the multi-bucket merge + last-page-signal semantics are unit
// tested in one place.
func mergeChatMemberPages(pages []map[string]interface{}) *chatMembersResult {
res := newChatMembersResult()
if len(pages) == 0 {
return res
}
for _, data := range pages {
addMemberBuckets(res, data)
}
applyLastPageSignals(res, pages[len(pages)-1])
return res
}
// normalizeMemberTypes validates the --member-types slice (already CSV-split by
// cobra) into a lowercased, deduped CSV string. Empty input is a no-op (return
// the API's default of all types). Any element outside {user, bot} is rejected.
func normalizeMemberTypes(raw []string) (string, error) {
if len(raw) == 0 {
return "", nil
}
seen := make(map[string]struct{}, len(raw))
out := make([]string, 0, len(raw))
for _, p := range raw {
p = strings.TrimSpace(strings.ToLower(p))
if p != "user" && p != "bot" {
return "", errs.NewValidationError(errs.SubtypeInvalidArgument, "invalid --member-types value %q: expected one of user, bot", p).WithParam("--member-types")
}
if _, dup := seen[p]; dup {
continue
}
seen[p] = struct{}{}
out = append(out, p)
}
return strings.Join(out, ","), nil
}
// warnIfConflictingPagingFlags mirrors the wiki list shortcuts: --page-token
// wins (single-page fetch from the supplied cursor) and --page-all is ignored.
func warnIfConflictingPagingFlags(runtime *common.RuntimeContext) {
if strings.TrimSpace(runtime.Str("page-token")) != "" && runtime.Bool("page-all") {
fmt.Fprintln(runtime.IO().ErrOut,
"warning: --page-token is set, so --page-all is ignored (single-page fetch from the supplied cursor)")
}
}
// writeChatMembersTruncationWarning emits a stderr warning for every
// server-side bucket cap reported in truncations[]. It uses the repo's plain
// "warning: <code>: <message>" convention (see shortcuts/common/runner.go and
// +chat-list's bot_strip_p2p) — no emoji, so it stays legible in CI logs and
// pipes regardless of terminal encoding.
func writeChatMembersTruncationWarning(w io.Writer, truncations []interface{}) {
for _, t := range truncations {
tm, ok := t.(map[string]interface{})
if !ok {
continue
}
memberType := valueOrAll(tm["member_type"])
limit := tm["limit"]
fmt.Fprintf(w, "warning: members_truncated: %s bucket capped at %v by server security config; the member list is INCOMPLETE\n", memberType, limit)
}
}
func valueOrAll(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "member"
}
func renderChatMembersPretty(w io.Writer, chatID string, res *chatMembersResult) {
fmt.Fprintf(w, "Chat: %s\n", chatID)
// Show the server-wide total next to the fetched count: when truncated or
// paged, total can far exceed len(users)/len(bots), and that gap is exactly
// what tells the reader how incomplete the list is.
fmt.Fprintf(w, "Users (%d%s):\n", len(res.users), totalSuffix(res.userTotal, len(res.users)))
for i, u := range res.users {
m, _ := u.(map[string]interface{})
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
}
fmt.Fprintf(w, "Bots (%d%s):\n", len(res.bots), totalSuffix(res.botTotal, len(res.bots)))
for i, b := range res.bots {
m, _ := b.(map[string]interface{})
fmt.Fprintf(w, " [%d] %s %s\n", i+1, valueOrDash(m["member_id"]), valueOrDash(m["name"]))
}
if len(res.truncations) > 0 {
fmt.Fprintln(w, "warning: result truncated by server security config (see truncations[]); the list is INCOMPLETE")
}
if res.hasMore {
fmt.Fprint(w, "More pages available; pass --page-all (and --page-limit 0 for everything)")
if res.pageToken != "" {
fmt.Fprintf(w, ", or --page-token %s to resume", res.pageToken)
}
fmt.Fprintln(w)
}
}
func valueOrDash(v interface{}) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return "-"
}
// totalSuffix renders " of <total>" when the server-reported total exceeds the
// number actually fetched (so a truncated/partial bucket is obvious), and ""
// when the total is absent or already matches the fetched count.
func totalSuffix(total interface{}, fetched int) string {
n, ok := toInt(total)
if !ok || n <= fetched {
return ""
}
return fmt.Sprintf(" of %d", n)
}
// toInt coerces a JSON-decoded number (float64 / json.Number / int) to int.
func toInt(v interface{}) (int, bool) {
switch n := v.(type) {
case float64:
return int(n), true
case int:
return n, true
case int64:
return int(n), true
case json.Number:
if i, err := n.Int64(); err == nil {
return int(i), true
}
}
return 0, false
}

View File

@@ -0,0 +1,325 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package im
import (
"bytes"
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
// page builds one list_members page payload shaped like the data object the
// server returns (users[]/bots[]/truncations[] plus paging + totals).
func cmlPage(users, bots, truncations []interface{}, hasMore bool, pageToken string) map[string]interface{} {
return map[string]interface{}{
"users": users,
"bots": bots,
"truncations": truncations,
"has_more": hasMore,
"page_token": pageToken,
"user_total": 324,
"bot_total": 2,
}
}
func us(ids ...string) []interface{} {
out := make([]interface{}, 0, len(ids))
for _, id := range ids {
out = append(out, map[string]interface{}{"member_id": id})
}
return out
}
// TestMergeChatMemberPages_MergesUsersAndBots covers Bug 1: every list bucket
// (users AND bots) must be concatenated across pages, not just one of them.
func TestMergeChatMemberPages_MergesUsersAndBots(t *testing.T) {
pages := []map[string]interface{}{
cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
cmlPage(us("u3"), us("b2", "b3"), []interface{}{}, false, ""),
}
res := mergeChatMemberPages(pages)
if len(res.users) != 3 {
t.Errorf("users: want 3 merged, got %d", len(res.users))
}
if len(res.bots) != 3 {
t.Errorf("bots: want 3 merged, got %d", len(res.bots))
}
}
// TestMergeChatMemberPages_TruncationsFromLastPage covers Bug 2: truncations[]
// is emitted only on the final page, so the merged view must take it from the
// last page rather than inherit page 1's empty slice.
func TestMergeChatMemberPages_TruncationsFromLastPage(t *testing.T) {
limit := []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}
pages := []map[string]interface{}{
cmlPage(us("u1"), us("b1"), []interface{}{}, true, "p2"),
cmlPage(us("u2"), nil, limit, false, ""),
}
res := mergeChatMemberPages(pages)
if len(res.truncations) != 1 {
t.Fatalf("truncations: want last page's 1 entry, got %d (%v)", len(res.truncations), res.truncations)
}
}
// TestMergeChatMemberPages_HasMoreAndTokenFromLastPage guards that paging
// signals come from the final page (so a --page-limit cutoff is visible).
func TestMergeChatMemberPages_HasMoreAndTokenFromLastPage(t *testing.T) {
pages := []map[string]interface{}{
cmlPage(us("u1"), nil, nil, true, "p2"),
cmlPage(us("u2"), nil, nil, true, "p3"), // loop stopped early; server still has more
}
res := mergeChatMemberPages(pages)
if !res.hasMore {
t.Error("has_more: want true from last page")
}
if res.pageToken != "p3" {
t.Errorf("page_token: want last page's p3, got %q", res.pageToken)
}
}
// TestMergeChatMemberPages_TotalsFromLastPage verifies user_total / bot_total
// are taken from the final page (not an earlier, possibly-different value).
func TestMergeChatMemberPages_TotalsFromLastPage(t *testing.T) {
pages := []map[string]interface{}{
{"users": us("u1"), "user_total": 999, "bot_total": 7, "has_more": true, "page_token": "p2"},
{"users": us("u2"), "user_total": 324, "bot_total": 2, "has_more": false, "page_token": ""},
}
res := mergeChatMemberPages(pages)
if n, _ := toInt(res.userTotal); n != 324 {
t.Errorf("user_total: want last page's 324, got %v", res.userTotal)
}
if n, _ := toInt(res.botTotal); n != 2 {
t.Errorf("bot_total: want last page's 2, got %v", res.botTotal)
}
}
// TestChatMembersValidate covers --chat-id presence + oc_ prefix enforcement.
func TestChatMembersValidate(t *testing.T) {
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
})
cases := []struct {
name string
chatID string
wantErr bool
}{
{"valid oc_", "oc_abc", false},
{"empty", "", true},
{"missing oc_ prefix", "abc123", true},
}
for _, c := range cases {
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": c.chatID}, nil, nil)
err := ImChatMembersList.Validate(context.Background(), rt)
if c.wantErr {
assertValidationError(t, c.name, err, "--chat-id")
continue
}
if err != nil {
t.Errorf("%s: unexpected error %v", c.name, err)
}
}
}
// assertValidationError checks err satisfies the repo's typed-error contract for
// a validation failure: a *errs.ValidationError carrying the expected Param, and
// problem metadata of category validation / subtype invalid_argument.
func assertValidationError(t *testing.T, ctx string, err error, wantParam string) {
t.Helper()
var ve *errs.ValidationError
if !errors.As(err, &ve) {
t.Errorf("%s: want *errs.ValidationError, got %T (%v)", ctx, err, err)
return
}
if ve.Param != wantParam {
t.Errorf("%s: Param = %q, want %q", ctx, ve.Param, wantParam)
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation || p.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("%s: problem = %+v (ok=%v), want category=%s subtype=%s", ctx, p, ok, errs.CategoryValidation, errs.SubtypeInvalidArgument)
}
}
func TestNormalizeMemberTypes(t *testing.T) {
cases := []struct {
in []string
want string
wantErr bool
}{
{nil, "", false},
{[]string{"user", "bot"}, "user,bot", false},
{[]string{"USER", "user"}, "user", false}, // lowercased + deduped
{[]string{"admin"}, "", true},
{[]string{""}, "", true},
}
for _, c := range cases {
got, err := normalizeMemberTypes(c.in)
if c.wantErr {
assertValidationError(t, fmt.Sprintf("normalizeMemberTypes(%v)", c.in), err, "--member-types")
continue
}
if err != nil {
t.Errorf("normalizeMemberTypes(%v): unexpected error %v", c.in, err)
}
if got != c.want {
t.Errorf("normalizeMemberTypes(%v) = %q, want %q", c.in, got, c.want)
}
}
}
// TestEffectiveChatMembersPageSize covers the --page-all max-page-size behavior:
// drain with no explicit size → max; explicit size → honored; single page → default.
func TestEffectiveChatMembersPageSize(t *testing.T) {
noop := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
return shortcutJSONResponse(200, map[string]interface{}{"code": 0, "data": cmlPage(nil, nil, nil, false, "")}), nil
})
cases := []struct {
name string
b map[string]bool
ints map[string]int
want int
}{
{"page-all, size unset -> max", map[string]bool{"page-all": true}, nil, chatMembersListMaxPageSize},
{"page-all, size explicit -> honored", map[string]bool{"page-all": true}, map[string]int{"page-size": 15}, 15},
{"single page, size unset -> default", nil, nil, chatMembersListDefaultPageSize},
}
for _, c := range cases {
rt := newChatMembersTestRuntime(t, noop, map[string]string{"chat-id": "oc_x"}, c.b, c.ints)
if got := effectiveChatMembersPageSize(rt); got != c.want {
t.Errorf("%s: want %d, got %d", c.name, c.want, got)
}
}
}
// newChatMembersTestRuntime registers the shortcut's flags and returns a
// user-identity runtime wired to the given RoundTripper for multi-page mocking.
func newChatMembersTestRuntime(t *testing.T, rt http.RoundTripper, str map[string]string, b map[string]bool, ints map[string]int) *common.RuntimeContext {
t.Helper()
runtime := newUserShortcutRuntime(t, rt)
cmd := &cobra.Command{Use: "test"}
cmd.Flags().String("chat-id", "", "")
cmd.Flags().String("member-id-type", "open_id", "")
cmd.Flags().StringSlice("member-types", nil, "")
cmd.Flags().String("page-token", "", "")
cmd.Flags().Bool("page-all", false, "")
cmd.Flags().Int("page-size", 20, "")
cmd.Flags().Int("page-limit", 10, "")
cmd.Flags().Int("page-delay", 200, "")
if err := cmd.ParseFlags(nil); err != nil {
t.Fatalf("ParseFlags: %v", err)
}
for k, v := range str {
if err := cmd.Flags().Set(k, v); err != nil {
t.Fatalf("set %s: %v", k, err)
}
}
for k, v := range b {
if err := cmd.Flags().Set(k, strconv.FormatBool(v)); err != nil {
t.Fatalf("set %s: %v", k, err)
}
}
for k, v := range ints {
if err := cmd.Flags().Set(k, strconv.Itoa(v)); err != nil {
t.Fatalf("set %s: %v", k, err)
}
}
runtime.Cmd = cmd
return runtime
}
// TestFetchChatMembers_PageAllMergesBucketsAndTruncations exercises the full
// fetch loop over mocked pages: users/bots merge across pages and the final
// page's truncations[] survives.
func TestFetchChatMembers_PageAllMergesBucketsAndTruncations(t *testing.T) {
calls := 0
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
if !strings.Contains(req.URL.Path, "/open-apis/im/v1/chats/oc_test/members/list") {
return shortcutJSONResponse(404, map[string]interface{}{"code": 1}), nil
}
calls++
token := req.URL.Query().Get("page_token")
if token == "" {
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": cmlPage(us("u1", "u2"), us("b1"), []interface{}{}, true, "p2"),
}), nil
}
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": cmlPage(us("u3"), us("b2"), []interface{}{map[string]interface{}{"limit": 100, "member_type": "user"}}, false, ""),
}), nil
})
runtime := newChatMembersTestRuntime(t, rt,
map[string]string{"chat-id": "oc_test"},
map[string]bool{"page-all": true},
map[string]int{"page-size": 2, "page-limit": 0, "page-delay": 0})
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
if err != nil {
t.Fatalf("fetchChatMembers: %v", err)
}
if calls != 2 {
t.Errorf("want 2 page calls, got %d", calls)
}
if len(res.users) != 3 {
t.Errorf("users: want 3, got %d", len(res.users))
}
if len(res.bots) != 2 {
t.Errorf("bots: want 2, got %d", len(res.bots))
}
if len(res.truncations) != 1 {
t.Errorf("truncations: want 1 from last page, got %d", len(res.truncations))
}
if res.hasMore {
t.Error("has_more: want false after draining all pages")
}
}
// TestFetchChatMembers_PageLimitStops verifies --page-limit caps the loop and
// leaves has_more=true so the caller knows the result is incomplete.
func TestFetchChatMembers_PageLimitStops(t *testing.T) {
seq := 0
rt := shortcutRoundTripFunc(func(req *http.Request) (*http.Response, error) {
// Every page reports more pages available, with an advancing token so the
// loop is stopped by --page-limit, not the non-advancing-token guard.
seq++
return shortcutJSONResponse(200, map[string]interface{}{
"code": 0,
"data": cmlPage(us("u"), nil, nil, true, fmt.Sprintf("p%d", seq)),
}), nil
})
runtime := newChatMembersTestRuntime(t, rt,
map[string]string{"chat-id": "oc_test"},
map[string]bool{"page-all": true},
map[string]int{"page-size": 1, "page-limit": 3, "page-delay": 0})
res, err := fetchChatMembers(context.Background(), runtime, "oc_test")
if err != nil {
t.Fatalf("fetchChatMembers: %v", err)
}
if len(res.users) != 3 {
t.Errorf("users: want 3 (capped at page-limit), got %d", len(res.users))
}
if !res.hasMore {
t.Error("has_more: want true (loop cut short by page-limit)")
}
errOut := runtime.IO().ErrOut.(*bytes.Buffer)
if !strings.Contains(errOut.String(), "reached page limit (3)") {
t.Errorf("want page-limit notice on stderr, got: %s", errOut.String())
}
}

View File

@@ -10,6 +10,7 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{ return []common.Shortcut{
ImChatCreate, ImChatCreate,
ImChatList, ImChatList,
ImChatMembersList,
ImChatMessageList, ImChatMessageList,
ImChatSearch, ImChatSearch,
ImChatUpdate, ImChatUpdate,

View File

@@ -51,9 +51,8 @@ func hintSendDraft(runtime *common.RuntimeContext, mailboxID, draftID string) {
// original message as read after a reply/reply-all/forward operation. // original message as read after a reply/reply-all/forward operation.
func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) { func hintMarkAsRead(runtime *common.RuntimeContext, mailboxID, originalMessageID string) {
fmt.Fprintf(runtime.IO().ErrOut, fmt.Fprintf(runtime.IO().ErrOut,
"tip: mark original as read? lark-cli mail user_mailbox.messages batch_modify_message"+ "tip: mark original as read? lark-cli mail +message-modify --mailbox '%s' --message-ids '%s' --remove-label-ids UNREAD\n",
` --params '{"user_mailbox_id":"%s"}' --data '{"message_ids":["%s"],"remove_label_ids":["UNREAD"]}'`+"\n", shellQuoteForHint(mailboxID), shellQuoteForHint(originalMessageID))
sanitizeForTerminal(mailboxID), sanitizeForTerminal(originalMessageID))
} }
// hintReadReceiptRequest prints a stderr tip when a message that the caller // hintReadReceiptRequest prints a stderr tip when a message that the caller

View File

@@ -465,14 +465,19 @@ func TestPrintWatchOutputSchema(t *testing.T) {
// TestHintMarkAsRead verifies hint mark as read. // TestHintMarkAsRead verifies hint mark as read.
func TestHintMarkAsRead(t *testing.T) { func TestHintMarkAsRead(t *testing.T) {
rt, _, stderr := newOutputRuntime(t) rt, _, stderr := newOutputRuntime(t)
// Inject ANSI escape + message ID to verify sanitization hintMarkAsRead(rt, "mail box;$(whoami)", "msg-\x1b[31m123 'quoted'\nnext")
hintMarkAsRead(rt, "me", "msg-\x1b[31m123")
out := stderr.String() out := stderr.String()
if strings.Contains(out, "\x1b[") { if strings.Contains(out, "\x1b[") {
t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out) t.Errorf("hintMarkAsRead should sanitize ANSI escapes, got: %q", out)
} }
if !strings.Contains(out, "msg-123") { if strings.Contains(out, "\nnext") {
t.Errorf("hintMarkAsRead should contain sanitized message ID, got: %q", out) t.Errorf("hintMarkAsRead should strip embedded newlines, got: %q", out)
}
if !strings.Contains(out, "--mailbox 'mail box;$(whoami)'") {
t.Errorf("hintMarkAsRead should quote mailbox for shell copy/paste, got: %q", out)
}
if !strings.Contains(out, "--message-ids 'msg-123 '\\''quoted'\\''next'") {
t.Errorf("hintMarkAsRead should quote message ID for shell copy/paste, got: %q", out)
} }
} }

View File

@@ -0,0 +1,482 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/auth"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
func messageManageID(suffix string) string {
return "msg_abcdefghijklmnop_" + suffix
}
func stubMessageManagePost(reg *httpmock.Registry, endpoint string, body map[string]interface{}) *httpmock.Stub {
stub := &httpmock.Stub{
Method: "POST",
URL: "/user_mailboxes/me/messages/" + endpoint,
Body: body,
}
reg.Register(stub)
return stub
}
func decodeMessageManageSummary(t *testing.T, data map[string]interface{}) ([]interface{}, []interface{}) {
t.Helper()
success, ok := data["success_message_ids"].([]interface{})
if !ok {
t.Fatalf("success_message_ids = %#v, want array", data["success_message_ids"])
}
failed, ok := data["failed_message_ids"].([]interface{})
if !ok {
t.Fatalf("failed_message_ids = %#v, want array", data["failed_message_ids"])
}
return success, failed
}
func requireMessageManageValidationParam(t *testing.T, err error, param string) *errs.ValidationError {
t.Helper()
if err == nil {
t.Fatalf("expected validation error for %s, got nil", param)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError for %s, got %T", param, err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed Problem for %s, got %T", param, err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("problem = %s/%s, want validation/invalid_argument", problem.Category, problem.Subtype)
}
if validationErr.Param != param {
t.Fatalf("param = %q, want %q", validationErr.Param, param)
}
return validationErr
}
func requireMessageManageFailedPrecondition(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected failed precondition error, got nil")
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed Problem, got %T", err)
}
if problem.Category != errs.CategoryValidation || problem.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("problem = %s/%s, want validation/failed_precondition", problem.Category, problem.Subtype)
}
}
func TestMessageManage_NormalizeMessageIDs(t *testing.T) {
id1 := messageManageID("1")
id2 := messageManageID("2")
got, err := normalizeMessageManageIDs([]string{id1, id2, id1})
if err != nil {
t.Fatalf("normalizeMessageManageIDs returned error: %v", err)
}
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
t.Fatalf("ids = %v, want [%s %s]", got, id1, id2)
}
got, err = normalizeMessageManageIDs([]string{id1 + "," + id2, id1})
if err != nil {
t.Fatalf("normalizeMessageManageIDs CSV/repeated returned error: %v", err)
}
if len(got) != 2 || got[0] != id1 || got[1] != id2 {
t.Fatalf("CSV/repeated ids = %v, want [%s %s]", got, id1, id2)
}
cases := [][]string{
{""},
{" id_with_leading_space_12345"},
{"msg_abcdefghijklmnop_1,msg_abcdefghijklmnop_2 "},
{"1234567890123456"},
{"short"},
{"msg_abcdefghijklmnop!"},
{"msg_abcdefghijklmnop\t"},
{"msg_abcdefghijklmnop_1\nmsg_abcdefghijklmnop_2"},
{"msg_abcdefghijklmnop_1", "msg_abcdefghijklmnop_2 "},
}
for _, tc := range cases {
_, err := normalizeMessageManageIDs(tc)
requireMessageManageValidationParam(t, err, "--message-ids")
}
}
func TestMessageModify_Metadata(t *testing.T) {
if MailMessageModify.Command != "+message-modify" {
t.Fatalf("Command = %q", MailMessageModify.Command)
}
if MailMessageModify.Risk != "write" {
t.Errorf("Risk = %q, want write", MailMessageModify.Risk)
}
if len(MailMessageModify.AuthTypes) != 1 || MailMessageModify.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v, want [user]", MailMessageModify.AuthTypes)
}
requiredScopes := map[string]bool{
"mail:user_mailbox.message:modify": true,
}
for _, scope := range MailMessageModify.Scopes {
delete(requiredScopes, scope)
}
if len(requiredScopes) != 0 {
t.Errorf("Scopes missing %v", requiredScopes)
}
if len(MailMessageModify.ConditionalScopes) != 1 || MailMessageModify.ConditionalScopes[0] != "mail:user_mailbox.folder:read" {
t.Errorf("ConditionalScopes = %v, want [mail:user_mailbox.folder:read]", MailMessageModify.ConditionalScopes)
}
flags := map[string]common.Flag{}
for _, fl := range MailMessageModify.Flags {
flags[fl.Name] = fl
}
for _, name := range []string{"mailbox", "message-ids", "add-label-ids", "remove-label-ids", "add-folder"} {
if _, ok := flags[name]; !ok {
t.Fatalf("missing --%s flag", name)
}
}
if flags["message-ids"].Type != "string_array" || !flags["message-ids"].Required {
t.Errorf("--message-ids = %#v, want required string_array", flags["message-ids"])
}
}
func TestMessageTrash_Metadata(t *testing.T) {
if MailMessageTrash.Command != "+message-trash" {
t.Fatalf("Command = %q", MailMessageTrash.Command)
}
if MailMessageTrash.Risk != "high-risk-write" {
t.Errorf("Risk = %q, want high-risk-write", MailMessageTrash.Risk)
}
if len(MailMessageTrash.AuthTypes) != 1 || MailMessageTrash.AuthTypes[0] != "user" {
t.Errorf("AuthTypes = %v, want [user]", MailMessageTrash.AuthTypes)
}
if len(MailMessageTrash.Scopes) != 1 || MailMessageTrash.Scopes[0] != "mail:user_mailbox.message:modify" {
t.Errorf("Scopes = %v, want [mail:user_mailbox.message:modify]", MailMessageTrash.Scopes)
}
}
func TestMessageModify_LabelOnlyDoesNotRequireFolderReadScope(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
token := auth.GetStoredToken("test-app", "ou_testuser")
if token == nil {
t.Fatal("expected test token")
}
token.Scope = strings.ReplaceAll(token.Scope, " mail:user_mailbox.folder:read", "")
if err := auth.SetStoredToken(token); err != nil {
t.Fatalf("SetStoredToken() error = %v", err)
}
id := messageManageID("1")
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--remove-label-ids", "UNREAD",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
removeLabels := body["remove_label_ids"].([]interface{})
if len(removeLabels) != 1 || removeLabels[0] != "UNREAD" {
t.Fatalf("remove_label_ids = %#v, want [UNREAD]", removeLabels)
}
}
func TestMessageModify_ReadReceiptRequestLabelIsSystemLabel(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--remove-label-ids", "read_receipt_request",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
removeLabels := body["remove_label_ids"].([]interface{})
if len(removeLabels) != 1 || removeLabels[0] != "READ_RECEIPT_REQUEST" {
t.Fatalf("remove_label_ids = %#v, want [READ_RECEIPT_REQUEST]", removeLabels)
}
}
func TestMessageModify_LabelFolderNormalizationAndValidationAPIs(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/labels/customA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"label_id": "customA"}}})
reg.Register(&httpmock.Stub{Method: "GET", URL: "/user_mailboxes/me/folders/folderA", Body: map[string]interface{}{"code": 0, "data": map[string]interface{}{"folder_id": "folderA"}}})
post := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-label-ids", "unread,customA",
"--remove-label-ids", "FLAGGED",
"--add-folder", "folderA",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if got := body["add_folder"]; got != "folderA" {
t.Errorf("add_folder = %v, want folderA", got)
}
addLabels := body["add_label_ids"].([]interface{})
if addLabels[0] != "UNREAD" || addLabels[1] != "customA" {
t.Errorf("add_label_ids = %#v, want [UNREAD customA]", addLabels)
}
removeLabels := body["remove_label_ids"].([]interface{})
if removeLabels[0] != "FLAGGED" {
t.Errorf("remove_label_ids = %#v, want [FLAGGED]", removeLabels)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 1 || success[0] != id || len(failed) != 0 {
t.Errorf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageModify_RejectsLabelIntersectionAndTrashFolder(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id := messageManageID("1")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-label-ids", "unread",
"--remove-label-ids", "UNREAD",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--add-label-ids")
if !strings.Contains(err.Error(), "label cannot be both added and removed") {
t.Fatalf("error = %v, want label intersection validation", err)
}
err = runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-folder", "trash",
}, f, stdout)
requireMessageManageValidationParam(t, err, "--add-folder")
if !strings.Contains(err.Error(), "use +message-trash") {
t.Fatalf("error = %v, want TRASH validation", err)
}
}
func TestMessageModify_EmptyOperationDoesNotCallPost(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id1 + "," + id2 + "," + id1,
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 2 || success[0] != id1 || success[1] != id2 || len(failed) != 0 {
t.Fatalf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageModify_BatchesAndAggregatesPartialFailure(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
ids := make([]string, 41)
for i := range ids {
ids[i] = messageManageID(fmt.Sprintf("%02d", i))
}
first := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
second := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
third := stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", strings.Join(ids, ","),
"--add-folder", "archive",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
for idx, stub := range []*httpmock.Stub{first, second, third} {
var body map[string]interface{}
if err := json.Unmarshal(stub.CapturedBody, &body); err != nil {
t.Fatalf("batch %d body unmarshal: %v", idx+1, err)
}
messageIDs := body["message_ids"].([]interface{})
want := []int{20, 20, 1}[idx]
if len(messageIDs) != want {
t.Fatalf("batch %d size = %d, want %d", idx+1, len(messageIDs), want)
}
if body["add_folder"] != "ARCHIVED" {
t.Fatalf("batch %d add_folder = %v, want ARCHIVED", idx+1, body["add_folder"])
}
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 21 || len(failed) != 20 {
t.Fatalf("success=%d failed=%d, want 21/20", len(success), len(failed))
}
}
func TestMessageModify_AllBatchesFailReturnsError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
stubMessageManagePost(reg, "batch_modify", map[string]interface{}{"code": 1230001, "msg": "bad request"})
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id,
"--add-folder", "archive",
}, f, stdout)
requireMessageManageFailedPrecondition(t, err)
}
func TestMessageModify_DryRunShowsPlanWithoutValidationGET(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageModify, []string{
"+message-modify",
"--message-ids", id1 + "," + id2,
"--add-label-ids", "customA",
"--add-folder", "folderA",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("dry-run failed: %v", err)
}
out := stdout.String()
for _, want := range []string{
`/user_mailboxes/me/messages/batch_modify`,
`validation_api_plan`,
`/user_mailboxes/me/labels/customA`,
`/user_mailboxes/me/folders/folderA`,
`will_validate`,
`batch_size`,
} {
if !strings.Contains(out, want) {
t.Fatalf("dry-run output missing %q; got %s", want, out)
}
}
}
func TestMessageTrash_RequiresYesAndBatches(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id1 := messageManageID("1")
id2 := messageManageID("2")
err := runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id1 + "," + id2,
}, f, stdout)
if err == nil {
t.Fatal("expected confirmation error, got nil")
}
if code := output.ExitCodeOf(err); code != output.ExitConfirmationRequired {
t.Fatalf("exit code = %d, want %d", code, output.ExitConfirmationRequired)
}
post := stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 0, "data": map[string]interface{}{}})
err = runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id1 + "," + id2,
"--yes",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected err with --yes: %v", err)
}
var body map[string]interface{}
if err := json.Unmarshal(post.CapturedBody, &body); err != nil {
t.Fatalf("unmarshal captured body: %v", err)
}
if got := len(body["message_ids"].([]interface{})); got != 2 {
t.Fatalf("message_ids len = %d, want 2", got)
}
success, failed := decodeMessageManageSummary(t, decodeShortcutEnvelopeData(t, stdout))
if len(success) != 2 || len(failed) != 0 {
t.Fatalf("summary success=%v failed=%v", success, failed)
}
}
func TestMessageTrash_AllBatchesFailReturnsError(t *testing.T) {
f, stdout, _, reg := mailShortcutTestFactory(t)
id := messageManageID("1")
stubMessageManagePost(reg, "batch_trash", map[string]interface{}{"code": 1230001, "msg": "bad request"})
err := runMountedMailShortcut(t, MailMessageTrash, []string{
"+message-trash",
"--message-ids", id,
"--yes",
}, f, stdout)
requireMessageManageFailedPrecondition(t, err)
}
func TestMessageManage_RejectsWhitespaceBeforeAPI(t *testing.T) {
id1 := messageManageID("1")
id2 := messageManageID("2")
cases := []struct {
name string
shortcut common.Shortcut
args []string
}{
{
name: "trash newline in repeated flag",
shortcut: MailMessageTrash,
args: []string{"+message-trash", "--message-ids", id1 + "\n" + id2, "--yes"},
},
{
name: "trash tab in csv flag",
shortcut: MailMessageTrash,
args: []string{"+message-trash", "--message-ids", id1 + ",\t" + id2, "--yes"},
},
{
name: "modify space in repeated flag",
shortcut: MailMessageModify,
args: []string{"+message-modify", "--message-ids", id1, "--message-ids", id2 + " ", "--add-folder", "archive"},
},
{
name: "modify space in csv flag",
shortcut: MailMessageModify,
args: []string{"+message-modify", "--message-ids", id1 + ", " + id2, "--add-folder", "archive"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
f, stdout, _, _ := mailShortcutTestFactory(t)
err := runMountedMailShortcut(t, tc.shortcut, tc.args, f, stdout)
if err == nil {
t.Fatal("expected validation error, got nil")
}
if code := output.ExitCodeOf(err); code != output.ExitValidation {
t.Fatalf("exit code = %d, want %d; err=%v", code, output.ExitValidation, err)
}
if !strings.Contains(err.Error(), "must not contain whitespace or control characters") {
t.Fatalf("error = %v, want whitespace/control validation", err)
}
})
}
}

View File

@@ -0,0 +1,141 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
type messageModifyInput struct {
MessageIDs []string
AddLabelIDs []string
RemoveLabelIDs []string
AddFolder string
CustomLabelIDs []string
CustomFolderID string
ValidationAPIPlans []validationAPIPlan
}
// MailMessageModify is the `+message-modify` shortcut: apply labels, unread
// state labels, or a folder move to existing messages in batches of 20.
var MailMessageModify = common.Shortcut{
Service: "mail",
Command: "+message-modify",
Description: "Modify existing mail messages by adding/removing label IDs or moving them to a folder. Batches message IDs in groups of 20 and keeps output compact.",
Risk: "write",
Scopes: []string{"mail:user_mailbox.message:modify"},
ConditionalScopes: []string{
"mail:user_mailbox.folder:read",
},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to modify; comma-separated or repeat the flag."},
{Name: "add-label-ids", Type: "string_slice", Desc: "Label IDs to add. System labels unread/important/other/flagged are normalized to upper case."},
{Name: "remove-label-ids", Type: "string_slice", Desc: "Label IDs to remove. System labels unread/important/other/flagged are normalized to upper case."},
{Name: "add-folder", Desc: "Folder ID to move messages to. System folders inbox/sent/spam/archive/archived are normalized; TRASH is rejected, use +message-trash."},
},
Validate: validateMessageModify,
DryRun: dryRunMessageModify,
Execute: executeMessageModify,
}
func validateMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
_, err := buildMessageModifyInput(rt)
return err
}
func dryRunMessageModify(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(rt)
input, _ := buildMessageModifyInput(rt)
api := common.NewDryRunAPI().
Desc("Modify messages sequentially in batches of 20; dry-run does not call label/folder validation APIs").
Set("batch_size", mailMessageManageBatchSize).
Set("batches", chunkMessageManageIDs(input.MessageIDs)).
Set("validation_api_plan", input.ValidationAPIPlans)
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
api = api.POST(mailboxPath(mailboxID, "messages", "batch_modify")).
Body(messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
}
return api
}
func executeMessageModify(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveMailboxID(rt)
input, err := buildMessageModifyInput(rt)
if err != nil {
return err
}
if err := validateCustomMessageManageLabels(rt, mailboxID, input.CustomLabelIDs); err != nil {
return err
}
if err := validateCustomMessageManageFolder(rt, mailboxID, input.CustomFolderID); err != nil {
return err
}
if len(input.AddLabelIDs) == 0 && len(input.RemoveLabelIDs) == 0 && input.AddFolder == "" {
emitMessageManageSummary(rt, messageManageSummary{
SuccessMessageIDs: input.MessageIDs,
FailedMessageIDs: []messageManageFailure{},
}, true)
return nil
}
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
for _, batch := range chunkMessageManageIDs(input.MessageIDs) {
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_modify"), nil,
messageManageBody(batch, input.AddLabelIDs, input.RemoveLabelIDs, input.AddFolder))
if err != nil {
for _, id := range batch {
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
}
continue
}
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
}
emitMessageManageSummary(rt, summary, false)
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
return mailFailedPreconditionError("all message modify batches failed")
}
return nil
}
func buildMessageModifyInput(rt *common.RuntimeContext) (messageModifyInput, error) {
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
if err != nil {
return messageModifyInput{}, err
}
addLabels, customAddLabels, err := normalizeMessageManageLabels(rt.StrSlice("add-label-ids"), "--add-label-ids")
if err != nil {
return messageModifyInput{}, err
}
removeLabels, customRemoveLabels, err := normalizeMessageManageLabels(rt.StrSlice("remove-label-ids"), "--remove-label-ids")
if err != nil {
return messageModifyInput{}, err
}
if err := validateLabelIntersection(addLabels, removeLabels); err != nil {
return messageModifyInput{}, err
}
folder, customFolder, err := normalizeMessageManageFolder(rt.Str("add-folder"))
if err != nil {
return messageModifyInput{}, err
}
customLabels := append(customAddLabels, customRemoveLabels...)
customFolderID := ""
if customFolder {
customFolderID = folder
}
return messageModifyInput{
MessageIDs: messageIDs,
AddLabelIDs: addLabels,
RemoveLabelIDs: removeLabels,
AddFolder: folder,
CustomLabelIDs: customLabels,
CustomFolderID: customFolderID,
ValidationAPIPlans: messageManageValidationPlan(resolveMailboxID(rt), customLabels, customFolderID),
}, nil
}

View File

@@ -0,0 +1,75 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"context"
"github.com/larksuite/cli/shortcuts/common"
)
// MailMessageTrash is the `+message-trash` shortcut: soft-delete existing
// messages in batches of 20 via batch_trash. Risk is high-risk-write, so the
// runner requires --yes before Execute.
var MailMessageTrash = common.Shortcut{
Service: "mail",
Command: "+message-trash",
Description: "Soft-delete existing mail messages. Batches message IDs in groups of 20 and calls batch_trash sequentially. Requires --yes.",
Risk: "high-risk-write",
Scopes: []string{"mail:user_mailbox.message:modify"},
AuthTypes: []string{"user"},
HasFormat: true,
Flags: []common.Flag{
{Name: "mailbox", Desc: "Mailbox email address that owns the messages (default: me)."},
{Name: "message-ids", Type: "string_array", Required: true, Desc: "Message IDs to soft-delete; comma-separated or repeat the flag."},
},
Validate: validateMessageTrash,
DryRun: dryRunMessageTrash,
Execute: executeMessageTrash,
}
func validateMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
_, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
return err
}
func dryRunMessageTrash(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
mailboxID := resolveMailboxID(rt)
messageIDs, _ := normalizeMessageManageIDs(rt.StrArray("message-ids"))
api := common.NewDryRunAPI().
Desc("Soft-delete messages sequentially in batches of 20").
Set("batch_size", mailMessageManageBatchSize).
Set("batches", chunkMessageManageIDs(messageIDs))
for _, batch := range chunkMessageManageIDs(messageIDs) {
api = api.POST(mailboxPath(mailboxID, "messages", "batch_trash")).
Body(map[string]interface{}{"message_ids": batch})
}
return api
}
func executeMessageTrash(ctx context.Context, rt *common.RuntimeContext) error {
mailboxID := resolveMailboxID(rt)
messageIDs, err := normalizeMessageManageIDs(rt.StrArray("message-ids"))
if err != nil {
return err
}
summary := messageManageSummary{FailedMessageIDs: []messageManageFailure{}}
for _, batch := range chunkMessageManageIDs(messageIDs) {
_, err := rt.CallAPITyped("POST", mailboxPath(mailboxID, "messages", "batch_trash"), nil,
map[string]interface{}{"message_ids": batch})
if err != nil {
for _, id := range batch {
summary.FailedMessageIDs = append(summary.FailedMessageIDs, messageManageFailure{MessageID: id, Reason: err.Error()})
}
continue
}
summary.SuccessMessageIDs = append(summary.SuccessMessageIDs, batch...)
}
emitMessageManageSummary(rt, summary, false)
if len(summary.SuccessMessageIDs) == 0 && len(summary.FailedMessageIDs) > 0 {
return mailFailedPreconditionError("all message trash batches failed")
}
return nil
}

View File

@@ -44,7 +44,7 @@ func mailShortcutTestFactory(t *testing.T) (*cmdutil.Factory, *bytes.Buffer, *by
RefreshToken: "test-refresh-token", RefreshToken: "test-refresh-token",
ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(), ExpiresAt: time.Now().Add(1 * time.Hour).UnixMilli(),
RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(), RefreshExpiresAt: time.Now().Add(24 * time.Hour).UnixMilli(),
Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly", Scope: "mail:user_mailbox.messages:write mail:user_mailbox.messages:read mail:user_mailbox.message:modify mail:user_mailbox.message:readonly mail:user_mailbox.message.address:read mail:user_mailbox.message.subject:read mail:user_mailbox.message.body:read mail:user_mailbox:readonly mail:user_mailbox.folder:read",
GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(), GrantedAt: time.Now().Add(-1 * time.Hour).UnixMilli(),
} }
if err := auth.SetStoredToken(token); err != nil { if err := auth.SetStoredToken(token); err != nil {

View File

@@ -0,0 +1,283 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package mail
import (
"fmt"
"io"
"strings"
"unicode"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
const mailMessageManageBatchSize = 20
var messageManageSystemLabels = map[string]string{
"UNREAD": "UNREAD",
"IMPORTANT": "IMPORTANT",
"OTHER": "OTHER",
"FLAGGED": "FLAGGED",
"READ_RECEIPT_REQUEST": "READ_RECEIPT_REQUEST",
}
var messageManageSystemFolders = map[string]string{
"INBOX": "INBOX",
"SENT": "SENT",
"SPAM": "SPAM",
"ARCHIVE": "ARCHIVED",
"ARCHIVED": "ARCHIVED",
}
type messageManageSummary struct {
SuccessMessageIDs []string `json:"success_message_ids"`
FailedMessageIDs []messageManageFailure `json:"failed_message_ids"`
}
type messageManageFailure struct {
MessageID string `json:"message_id"`
Reason string `json:"reason"`
}
type validationAPIPlan struct {
Method string `json:"method"`
Path string `json:"path"`
WillValidate bool `json:"will_validate"`
}
func normalizeMessageManageIDs(raw []string) ([]string, error) {
if len(raw) == 0 {
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
}
parts, err := splitMessageManageIDTokens(raw)
if err != nil {
return nil, err
}
ids := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for i, part := range parts {
if part == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
id := strings.TrimSpace(part)
if id == "" {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d is empty; remove extra commas or provide valid message IDs", i+1)
}
if id != part {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain leading or trailing whitespace", i+1, part)
}
if err := validateMessageManageID(id, i); err != nil {
return nil, err
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
if len(ids) == 0 {
return nil, mailValidationParamError("--message-ids", "--message-ids is required")
}
return ids, nil
}
func splitMessageManageIDTokens(raw []string) ([]string, error) {
parts := make([]string, 0, len(raw))
for i, token := range raw {
for _, r := range token {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return nil, mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", i+1, token)
}
}
parts = append(parts, strings.Split(token, ",")...)
}
return parts, nil
}
func validateMessageManageID(id string, index int) error {
if len(id) < 16 {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): length must be at least 16 characters", index+1, id)
}
if strings.Trim(id, "0123456789") == "" {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): numeric primary IDs are not supported; pass the Open API message_id from mail output", index+1, id)
}
for _, r := range id {
if unicode.IsSpace(r) || unicode.IsControl(r) {
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): must not contain whitespace or control characters", index+1, id)
}
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
continue
}
switch r {
case '+', '/', '=', '_', '-':
continue
default:
return mailValidationParamError("--message-ids", "--message-ids entry %d (%q): contains characters outside the Open API message_id character set", index+1, id)
}
}
return nil
}
func normalizeMessageManageLabels(raw []string, flagName string) ([]string, []string, error) {
labels := make([]string, 0, len(raw))
custom := make([]string, 0, len(raw))
seen := make(map[string]struct{}, len(raw))
for i, part := range raw {
id := strings.TrimSpace(part)
if id == "" {
return nil, nil, mailValidationParamError(flagName, "%s entry %d is empty; remove extra commas or provide valid label IDs", flagName, i+1)
}
if id != part {
return nil, nil, mailValidationParamError(flagName, "%s entry %d (%q): must not contain leading or trailing whitespace", flagName, i+1, part)
}
normalized := id
if system, ok := messageManageSystemLabels[strings.ToUpper(id)]; ok {
normalized = system
} else {
custom = append(custom, id)
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
labels = append(labels, normalized)
}
if len(labels) > 20 {
return nil, nil, mailValidationParamError(flagName, "%s accepts at most 20 label IDs (got %d)", flagName, len(labels))
}
return labels, custom, nil
}
func validateLabelIntersection(add, remove []string) error {
removeSet := make(map[string]struct{}, len(remove))
for _, id := range remove {
removeSet[id] = struct{}{}
}
for _, id := range add {
if _, ok := removeSet[id]; ok {
return mailValidationParamError("--add-label-ids", "label cannot be both added and removed: %s", id)
}
}
return nil
}
func normalizeMessageManageFolder(raw string) (string, bool, error) {
if raw == "" {
return "", false, nil
}
folder := strings.TrimSpace(raw)
if folder == "" {
return "", false, mailValidationParamError("--add-folder", "--add-folder must not be empty")
}
if folder != raw {
return "", false, mailValidationParamError("--add-folder", "--add-folder %q must not contain leading or trailing whitespace", raw)
}
if strings.EqualFold(folder, "TRASH") {
return "", false, mailValidationParamError("--add-folder", "TRASH is not supported by +message-modify; use +message-trash")
}
if system, ok := messageManageSystemFolders[strings.ToUpper(folder)]; ok {
return system, false, nil
}
return folder, true, nil
}
func chunkMessageManageIDs(ids []string) [][]string {
if len(ids) == 0 {
return nil
}
chunks := make([][]string, 0, (len(ids)+mailMessageManageBatchSize-1)/mailMessageManageBatchSize)
for start := 0; start < len(ids); start += mailMessageManageBatchSize {
end := start + mailMessageManageBatchSize
if end > len(ids) {
end = len(ids)
}
chunks = append(chunks, ids[start:end])
}
return chunks
}
func validateCustomMessageManageLabels(rt *common.RuntimeContext, mailboxID string, ids []string) error {
if len(ids) == 0 {
return nil
}
if err := validateLabelReadScope(rt); err != nil {
return err
}
seen := map[string]struct{}{}
for _, id := range ids {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "labels", id), nil, nil); err != nil {
return mailDecorateProblemMessage(err, "label not found: %s", id)
}
}
return nil
}
func validateCustomMessageManageFolder(rt *common.RuntimeContext, mailboxID, id string) error {
if id == "" {
return nil
}
if err := validateFolderReadScope(rt); err != nil {
return err
}
if _, err := rt.CallAPITyped("GET", mailboxPath(mailboxID, "folders", id), nil, nil); err != nil {
return mailDecorateProblemMessage(err, "folder not found: %s", id)
}
return nil
}
func messageManageBody(ids, addLabels, removeLabels []string, addFolder string) map[string]interface{} {
body := map[string]interface{}{"message_ids": ids}
if len(addLabels) > 0 {
body["add_label_ids"] = addLabels
}
if len(removeLabels) > 0 {
body["remove_label_ids"] = removeLabels
}
if addFolder != "" {
body["add_folder"] = addFolder
}
return body
}
func messageManageValidationPlan(mailboxID string, customLabels []string, customFolder string) []validationAPIPlan {
plans := make([]validationAPIPlan, 0, len(customLabels)+1)
seenLabels := map[string]struct{}{}
for _, id := range customLabels {
if _, ok := seenLabels[id]; ok {
continue
}
seenLabels[id] = struct{}{}
plans = append(plans, validationAPIPlan{
Method: "GET",
Path: mailboxPath(mailboxID, "labels", id),
WillValidate: true,
})
}
if customFolder != "" {
plans = append(plans, validationAPIPlan{
Method: "GET",
Path: mailboxPath(mailboxID, "folders", customFolder),
WillValidate: true,
})
}
return plans
}
func emitMessageManageSummary(rt *common.RuntimeContext, summary messageManageSummary, noAPICalls bool) {
rt.OutFormat(summary, &output.Meta{Count: len(summary.SuccessMessageIDs)}, func(w io.Writer) {
fmt.Fprintf(w, "success_message_ids: %d\n", len(summary.SuccessMessageIDs))
fmt.Fprintf(w, "failed_message_ids: %d\n", len(summary.FailedMessageIDs))
if noAPICalls {
fmt.Fprintln(w, "No changes requested; no API calls were made.")
}
for _, item := range summary.FailedMessageIDs {
fmt.Fprintf(w, "- %s: %s\n", item.MessageID, item.Reason)
}
})
}

View File

@@ -10,6 +10,8 @@ func Shortcuts() []common.Shortcut {
return []common.Shortcut{ return []common.Shortcut{
MailMessage, MailMessage,
MailMessages, MailMessages,
MailMessageModify,
MailMessageTrash,
MailThread, MailThread,
MailTriage, MailTriage,
MailWatch, MailWatch,

View File

@@ -715,9 +715,15 @@ func markdownUploadProblem(err error, action string) error {
case 90003087: case 90003087:
appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.") appendMarkdownProblemHint(err, "The current tenant or user may not have document capabilities enabled. Ask an administrator to verify document-module access.")
case 1061003, 1061044: case 1061003, 1061044:
appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the token you passed to the command.") appendMarkdownProblemHint(err, "Check whether the target folder or wiki node still exists, and verify the parent token type. For Drive folders, pass --folder-token with a Drive folder token/URL; for wiki nodes, pass --wiki-token with a wiki node token/URL.")
case 1061004, 1062501: case 1061004, 1062501:
appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.") appendMarkdownProblemHint(err, "Check whether the current identity has write access to the target folder or wiki node.")
case 1061101:
appendMarkdownProblemHint(err, "The target Drive/wiki storage quota is exhausted. Free space, choose another parent folder/wiki node, or ask an administrator to raise quota before retrying.")
case 233523001:
appendMarkdownProblemHint(err, "The upstream document service returned a transient server error. Retry later; if it repeats, keep the log_id/request_id for service-side investigation.")
case 99991400:
appendMarkdownProblemHint(err, "The upload API is rate limited. Stop immediate retries and retry later with exponential backoff.")
} }
} }
return err return err

View File

@@ -9,6 +9,7 @@ import (
"io" "io"
"strings" "strings"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
) )
@@ -30,27 +31,19 @@ var MarkdownCreate = common.Shortcut{
Tips: []string{ Tips: []string{
"Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.", "Omit both --folder-token and --wiki-token to create the Markdown file in the caller's Drive root folder.",
"Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.", "Use --wiki-token <wiki_node_token> to create the Markdown file under a wiki node; the shortcut maps this to parent_type=wiki automatically.",
"--folder-token and --wiki-token also accept full Lark URLs and normalize them to the required token.",
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateMarkdownSpec(runtime, markdownUploadSpec{ spec, err := readMarkdownCreateSpec(runtime)
FileName: strings.TrimSpace(runtime.Str("name")), if err != nil {
FolderToken: strings.TrimSpace(runtime.Str("folder-token")), return err
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")), }
FilePath: strings.TrimSpace(runtime.Str("file")), return validateMarkdownSpec(runtime, spec, true)
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}, true)
}, },
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
spec := markdownUploadSpec{ spec, err := readMarkdownCreateSpec(runtime)
FileName: strings.TrimSpace(runtime.Str("name")), if err != nil {
FolderToken: strings.TrimSpace(runtime.Str("folder-token")), return common.NewDryRunAPI().Set("error", err.Error())
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
} }
fileSize, err := markdownSourceSize(runtime, spec) fileSize, err := markdownSourceSize(runtime, spec)
if err != nil { if err != nil {
@@ -71,14 +64,9 @@ var MarkdownCreate = common.Shortcut{
return dry return dry
}, },
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
spec := markdownUploadSpec{ spec, err := readMarkdownCreateSpec(runtime)
FileName: strings.TrimSpace(runtime.Str("name")), if err != nil {
FolderToken: strings.TrimSpace(runtime.Str("folder-token")), return err
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
} }
fileSize, err := markdownSourceSize(runtime, spec) fileSize, err := markdownSourceSize(runtime, spec)
if err != nil { if err != nil {
@@ -115,3 +103,139 @@ var MarkdownCreate = common.Shortcut{
return nil return nil
}, },
} }
func readMarkdownCreateSpec(runtime *common.RuntimeContext) (markdownUploadSpec, error) {
spec := markdownUploadSpec{
FileName: strings.TrimSpace(runtime.Str("name")),
FolderToken: strings.TrimSpace(runtime.Str("folder-token")),
WikiToken: strings.TrimSpace(runtime.Str("wiki-token")),
FilePath: strings.TrimSpace(runtime.Str("file")),
FileSet: runtime.Changed("file"),
Content: runtime.Str("content"),
ContentSet: runtime.Changed("content"),
}
return normalizeMarkdownCreateTargetSpec(spec)
}
func normalizeMarkdownCreateTargetSpec(spec markdownUploadSpec) (markdownUploadSpec, error) {
if spec.FolderToken != "" {
token, err := normalizeMarkdownFolderToken(spec.FolderToken)
if err != nil {
return markdownUploadSpec{}, err
}
spec.FolderToken = token
}
if spec.WikiToken != "" {
token, err := normalizeMarkdownWikiToken(spec.WikiToken)
if err != nil {
return markdownUploadSpec{}, err
}
spec.WikiToken = token
}
return spec, nil
}
func normalizeMarkdownFolderToken(token string) (string, error) {
token = strings.TrimSpace(token)
if strings.Contains(token, "://") {
ref, ok := common.ParseResourceURL(token)
if !ok {
return "", markdownValidationParamError("--folder-token", "--folder-token URL is unsupported").
WithHint("Pass a Drive folder URL or raw folder token.")
}
if ref.Type != "folder" {
return "", markdownValidationParamError("--folder-token",
"--folder-token must identify a Drive folder; got a %s URL",
ref.Type,
).WithHint("Use --wiki-token for wiki nodes or pass a Drive folder URL/token.")
}
if err := validateMarkdownTargetTokenName(ref.Token, "--folder-token"); err != nil {
return "", err
}
return ref.Token, nil
}
if err := rejectMarkdownPartialToken(token, "--folder-token"); err != nil {
return "", err
}
switch markdownKnownResourceTokenKind(token) {
case "wiki":
return "", markdownValidationParamError("--folder-token", "--folder-token looks like a wiki node token").
WithHint("Pass it with --wiki-token instead.")
case "doc", "docx", "sheet", "bitable", "mindnote", "slides", "file":
return "", markdownValidationParamError("--folder-token", "--folder-token must be a Drive folder token, not a %s token", markdownKnownResourceTokenKind(token))
}
if err := validateMarkdownTargetTokenName(token, "--folder-token"); err != nil {
return "", err
}
return token, nil
}
func normalizeMarkdownWikiToken(token string) (string, error) {
token = strings.TrimSpace(token)
if strings.Contains(token, "://") {
ref, ok := common.ParseResourceURL(token)
if !ok {
return "", markdownValidationParamError("--wiki-token", "--wiki-token URL is unsupported").
WithHint("Pass a wiki node URL or raw wiki node token.")
}
if ref.Type != "wiki" {
return "", markdownValidationParamError("--wiki-token",
"--wiki-token must identify a wiki node; got a %s URL",
ref.Type,
).WithHint("Resolve document URLs with `lark-cli wiki +node-get --node-token <url>` and use the returned node_token.")
}
if err := validateMarkdownTargetTokenName(ref.Token, "--wiki-token"); err != nil {
return "", err
}
return ref.Token, nil
}
if err := rejectMarkdownPartialToken(token, "--wiki-token"); err != nil {
return "", err
}
if kind := markdownKnownResourceTokenKind(token); kind != "" && kind != "wiki" {
return "", markdownValidationParamError("--wiki-token", "--wiki-token must be a wiki node token, not a %s token", kind)
}
if err := validateMarkdownTargetTokenName(token, "--wiki-token"); err != nil {
return "", err
}
return token, nil
}
func rejectMarkdownPartialToken(token, flagName string) error {
if strings.ContainsAny(token, "/?#") {
return markdownValidationParamError(flagName, "%s must be a raw token, not a path, query, or fragment", flagName).
WithHint("Pass a full Lark URL, or copy only the token value without path/query/fragment characters.")
}
return nil
}
func validateMarkdownTargetTokenName(token, flagName string) error {
if err := validate.ResourceName(token, flagName); err != nil {
return markdownValidationParamError(flagName, "%s", err).WithCause(err)
}
return nil
}
func markdownKnownResourceTokenKind(token string) string {
lower := strings.ToLower(strings.TrimSpace(token))
switch {
case strings.HasPrefix(lower, "wik"):
return "wiki"
case strings.HasPrefix(lower, "docx"):
return "docx"
case strings.HasPrefix(lower, "doc"):
return "doc"
case strings.HasPrefix(lower, "sht"):
return "sheet"
case strings.HasPrefix(lower, "bas"):
return "bitable"
case strings.HasPrefix(lower, "mn"):
return "mindnote"
case strings.HasPrefix(lower, "sld"):
return "slides"
case strings.HasPrefix(lower, "box"), strings.HasPrefix(lower, "file"):
return "file"
default:
return ""
}
}

View File

@@ -446,6 +446,173 @@ func TestMarkdownCreateDryRunWithWikiToken(t *testing.T) {
} }
} }
func TestMarkdownCreateDryRunNormalizesFolderURL(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--folder-token", "https://feishu.cn/drive/folder/fldcnMarkdownTarget",
"--dry-run",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, `"parent_type": "explorer"`) {
t.Fatalf("dry-run missing explorer parent_type: %s", out)
}
if !strings.Contains(out, `"parent_node": "fldcnMarkdownTarget"`) {
t.Fatalf("dry-run did not normalize folder URL to token: %s", out)
}
if strings.Contains(out, "https://feishu.cn/drive/folder/") {
t.Fatalf("dry-run leaked raw folder URL instead of token: %s", out)
}
}
func TestMarkdownCreateRejectsWikiURLInFolderToken(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--folder-token", "https://feishu.cn/wiki/wikcnWrongFlag",
}, f, stdout)
if err == nil {
t.Fatalf("expected folder-token URL type error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
if !strings.Contains(p.Message, "must identify a Drive folder") || !strings.Contains(p.Hint, "Use --wiki-token") {
t.Fatalf("expected folder-token URL type error, got %v", err)
}
}
func TestMarkdownCreateRejectsDocURLInWikiToken(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())
err := mountAndRunMarkdown(t, MarkdownCreate, []string{
"+create",
"--name", "README.md",
"--content", "# hello",
"--wiki-token", "https://feishu.cn/docx/docxWrongFlag",
}, f, stdout)
if err == nil {
t.Fatalf("expected wiki-token URL type error, got nil")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
if !strings.Contains(p.Message, "must identify a wiki node") || !strings.Contains(p.Hint, "+node-get") {
t.Fatalf("expected wiki-token URL type error, got %v", err)
}
}
func TestNormalizeMarkdownTargetTokensRejectAmbiguousInputs(t *testing.T) {
t.Parallel()
tests := []struct {
name string
run func() (string, error)
wantMsg string
wantHint string
}{
{
name: "wiki token passed as folder token",
run: func() (string, error) { return normalizeMarkdownFolderToken("wik_placeholder_wrong") },
wantMsg: "--folder-token looks like a wiki node token",
wantHint: "--wiki-token",
},
{
name: "folder token path fragment",
run: func() (string, error) { return normalizeMarkdownFolderToken("folder_token/child") },
wantMsg: "--folder-token must be a raw token",
wantHint: "full Lark URL",
},
{
name: "doc token passed as wiki token",
run: func() (string, error) { return normalizeMarkdownWikiToken("docx_placeholder_wrong") },
wantMsg: "--wiki-token must be a wiki node token",
wantHint: "",
},
{
name: "wiki token query fragment",
run: func() (string, error) { return normalizeMarkdownWikiToken("wik_placeholder?from=copy") },
wantMsg: "--wiki-token must be a raw token",
wantHint: "path/query/fragment",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := tt.run()
if err == nil {
t.Fatalf("expected validation error")
}
p, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("ProblemOf() ok=false for %T: %v", err, err)
}
if !strings.Contains(p.Message, tt.wantMsg) {
t.Fatalf("message = %q, want substring %q", p.Message, tt.wantMsg)
}
if tt.wantHint != "" && !strings.Contains(p.Hint, tt.wantHint) {
t.Fatalf("hint = %q, want substring %q", p.Hint, tt.wantHint)
}
})
}
}
func TestNormalizeMarkdownTargetTokensAcceptRawTokens(t *testing.T) {
t.Parallel()
folderToken, err := normalizeMarkdownFolderToken("folder_token_raw")
if err != nil {
t.Fatalf("normalizeMarkdownFolderToken() error = %v", err)
}
if folderToken != "folder_token_raw" {
t.Fatalf("folder token = %q", folderToken)
}
wikiToken, err := normalizeMarkdownWikiToken("wik_placeholder_raw")
if err != nil {
t.Fatalf("normalizeMarkdownWikiToken() error = %v", err)
}
if wikiToken != "wik_placeholder_raw" {
t.Fatalf("wiki token = %q", wikiToken)
}
}
func TestMarkdownUploadProblemAddsQuotaAndServerHints(t *testing.T) {
t.Parallel()
quotaErr := errs.NewAPIError(errs.SubtypeQuotaExceeded, "file quota exceeded").WithCode(1061101)
got := markdownUploadProblem(quotaErr, markdownUploadAllAction)
p, ok := errs.ProblemOf(got)
if !ok {
t.Fatalf("ProblemOf(quotaErr) ok=false")
}
if !strings.Contains(p.Hint, "storage quota is exhausted") {
t.Fatalf("quota hint = %q", p.Hint)
}
serverErr := errs.NewAPIError(errs.SubtypeServerError, "NA").WithCode(233523001).WithRetryable()
got = markdownUploadProblem(serverErr, markdownUploadAllAction)
p, ok = errs.ProblemOf(got)
if !ok {
t.Fatalf("ProblemOf(serverErr) ok=false")
}
if !p.Retryable || !strings.Contains(p.Hint, "transient server error") {
t.Fatalf("server retryable=%v hint=%q", p.Retryable, p.Hint)
}
}
func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) { func TestMarkdownCreateDryRunReportsSourceFileError(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig()) f, stdout, _, _ := cmdutil.TestFactory(t, markdownTestConfig())

View File

@@ -28,7 +28,12 @@ import (
const minutesDetailLogPrefix = "[minutes +detail]" const minutesDetailLogPrefix = "[minutes +detail]"
// Error codes from the minutes API. // Error codes from the minutes API.
const minutesDetailNoReadPermissionCode = 2091005 const (
minutesDetailProcessingCode = 2091003
minutesDetailNoReadPermissionCode = 2091005
minutesDetailWaitTimeoutDefault = 300
minutesDetailWaitIntervalDefault = 15
)
var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`) var validMinuteTokenDetail = regexp.MustCompile(`^[a-z0-9]+$`)
@@ -40,19 +45,31 @@ var scopesDetailMinuteTokens = []string{
// minuteDetailItem represents a single minute detail result. // minuteDetailItem represents a single minute detail result.
type minuteDetailItem struct { type minuteDetailItem struct {
MinuteToken string `json:"minute_token"` MinuteToken string `json:"minute_token"`
Status string `json:"status,omitempty"`
Title string `json:"title"` Title string `json:"title"`
NoteID string `json:"note_id"` NoteID string `json:"note_id"`
Artifacts map[string]any `json:"artifacts,omitempty"` Artifacts map[string]any `json:"artifacts,omitempty"`
Retryable bool `json:"retryable,omitempty"`
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Hint string `json:"hint,omitempty"`
NextCommand string `json:"next_command,omitempty"`
} }
// fetchMinuteDetail queries a single minute's metadata and selected artifacts. // fetchMinuteDetail queries a single minute's metadata and selected artifacts.
func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem { func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minuteToken string) *minuteDetailItem {
data, err := runtime.CallAPITyped(http.MethodGet, artifactFlags := requestedMinutesDetailArtifactFlags(runtime)
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil) waitReady := runtime.Bool("wait-ready")
waitTimeout, waitInterval := minutesDetailWaitConfig(runtime)
data, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
return runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s", validate.EncodePathSegment(minuteToken)), nil, nil)
})
if err != nil { if err != nil {
result := &minuteDetailItem{MinuteToken: minuteToken} result := &minuteDetailItem{MinuteToken: minuteToken}
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode { if isMinutesDetailProcessingError(err) {
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute metadata is still being generated")
} else if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailNoReadPermissionCode {
result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken) result.Error = fmt.Sprintf("No read permission for minute %s. Ask the minute owner for minute file read permission", minuteToken)
} else { } else {
result.Error = fmt.Sprintf("failed to query minute: %v", err) result.Error = fmt.Sprintf("failed to query minute: %v", err)
@@ -81,10 +98,16 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
needKeyword := runtime.Bool("keyword") needKeyword := runtime.Bool("keyword")
if needSummary || needTodo || needChapter || needTranscript || needKeyword { if needSummary || needTodo || needChapter || needTranscript || needKeyword {
artData, err := runtime.CallAPITyped(http.MethodGet, artData, err := callMinutesDetailAPIUntilReady(ctx, runtime, waitReady, waitTimeout, waitInterval, func() (map[string]interface{}, error) {
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil) return runtime.CallAPITyped(http.MethodGet,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/artifacts", validate.EncodePathSegment(minuteToken)), nil, nil)
})
if err != nil { if err != nil {
fmt.Fprintf(runtime.IO().ErrOut, "%s failed to fetch artifacts for %s: %v\n", minutesDetailLogPrefix, minuteToken, err) if isMinutesDetailProcessingError(err) {
markMinutesDetailProcessing(result, minuteToken, artifactFlags, "minute artifacts are still being generated")
} else {
result.Error = fmt.Sprintf("failed to query minute artifacts: %v", err)
}
} else { } else {
artifacts := make(map[string]any) artifacts := make(map[string]any)
if needSummary { if needSummary {
@@ -133,6 +156,78 @@ func fetchMinuteDetail(ctx context.Context, runtime *common.RuntimeContext, minu
return result return result
} }
func isMinutesDetailProcessingError(err error) bool {
if p, ok := errs.ProblemOf(err); ok && p.Code == minutesDetailProcessingCode {
return true
}
return false
}
func minutesDetailWaitConfig(runtime *common.RuntimeContext) (time.Duration, time.Duration) {
timeoutSeconds, intervalSeconds := normalizeMinutesDetailWaitSeconds(runtime.Int("wait-timeout-seconds"), runtime.Int("wait-interval-seconds"))
return time.Duration(timeoutSeconds) * time.Second, time.Duration(intervalSeconds) * time.Second
}
func normalizeMinutesDetailWaitSeconds(timeoutSeconds, intervalSeconds int) (int, int) {
if timeoutSeconds <= 0 {
timeoutSeconds = minutesDetailWaitTimeoutDefault
}
if intervalSeconds <= 0 {
intervalSeconds = minutesDetailWaitIntervalDefault
}
return timeoutSeconds, intervalSeconds
}
func callMinutesDetailAPIUntilReady(ctx context.Context, runtime *common.RuntimeContext, waitReady bool, timeout, interval time.Duration, call func() (map[string]interface{}, error)) (map[string]interface{}, error) {
deadline := time.Now().Add(timeout)
for {
data, err := call()
if err == nil || !waitReady || !isMinutesDetailProcessingError(err) {
return data, err
}
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
remaining := time.Until(deadline)
if remaining <= 0 || interval > remaining {
return nil, err
}
fmt.Fprintf(runtime.IO().ErrOut, "%s minute is still processing; retrying in %s\n", minutesDetailLogPrefix, interval)
timer := time.NewTimer(interval)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
}
func requestedMinutesDetailArtifactFlags(runtime *common.RuntimeContext) []string {
var flags []string
for _, flag := range []string{"summary", "todo", "chapter", "keyword", "transcript"} {
if runtime.Bool(flag) {
flags = append(flags, "--"+flag)
}
}
return flags
}
func markMinutesDetailProcessing(result *minuteDetailItem, minuteToken string, artifactFlags []string, reason string) {
result.Status = "processing"
result.Retryable = true
result.Error = reason
result.Hint = "The minute is still being generated. Retry later, or rerun the next_command to wait until it is ready."
result.NextCommand = minutesDetailNextCommand(minuteToken, artifactFlags)
}
func minutesDetailNextCommand(minuteToken string, artifactFlags []string) string {
parts := []string{"lark-cli", "minutes", "+detail", "--minute-tokens", minuteToken}
parts = append(parts, artifactFlags...)
parts = append(parts, "--wait-ready")
return strings.Join(parts, " ")
}
// saveDetailTranscript persists transcript bytes to the canonical artifact path. // saveDetailTranscript persists transcript bytes to the canonical artifact path.
// With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/ // With --output-dir, transcripts land under <output-dir>/artifact-<title>-<token>/
// to mirror the legacy `vc +notes` layout. Otherwise falls back to the default // to mirror the legacy `vc +notes` layout. Otherwise falls back to the default
@@ -201,6 +296,9 @@ var MinutesDetail = common.Shortcut{
{Name: "keyword", Type: "bool", Desc: "include keywords"}, {Name: "keyword", Type: "bool", Desc: "include keywords"},
{Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"}, {Name: "output-dir", Desc: "output directory for transcript files (default: ./minutes/{minute_token}/)"},
{Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"}, {Name: "overwrite", Type: "bool", Desc: "overwrite existing transcript files"},
{Name: "wait-ready", Type: "bool", Desc: "wait until minute metadata/artifacts are ready", Hidden: true},
{Name: "wait-timeout-seconds", Type: "int", Default: "300", Desc: "maximum seconds to wait for readiness", Hidden: true},
{Name: "wait-interval-seconds", Type: "int", Default: "15", Desc: "seconds between readiness checks", Hidden: true},
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
tokens := common.SplitCSV(runtime.Str("minute-tokens")) tokens := common.SplitCSV(runtime.Str("minute-tokens"))
@@ -282,8 +380,15 @@ var MinutesDetail = common.Shortcut{
for _, r := range results { for _, r := range results {
row := map[string]interface{}{"minute_token": r.MinuteToken} row := map[string]interface{}{"minute_token": r.MinuteToken}
if r.Error != "" { if r.Error != "" {
row["status"] = "FAIL" if r.Status == "processing" {
row["status"] = "PROCESSING"
} else {
row["status"] = "FAIL"
}
row["error"] = r.Error row["error"] = r.Error
if r.NextCommand != "" {
row["next_command"] = r.NextCommand
}
} else { } else {
row["status"] = "OK" row["status"] = "OK"
row["title"] = r.Title row["title"] = r.Title

View File

@@ -9,6 +9,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/http"
"os" "os"
"strings" "strings"
"sync" "sync"
@@ -108,6 +109,17 @@ func detailArtifactsStub(token, transcript string) *httpmock.Stub {
} }
} }
func detailProcessingStub(path string) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: path,
Body: map[string]interface{}{
"code": 2091003,
"msg": "minute is processing",
},
}
}
func TestDetail_Validation_MissingMinuteTokens(t *testing.T) { func TestDetail_Validation_MissingMinuteTokens(t *testing.T) {
f, _, _, _ := cmdutil.TestFactory(t, defaultConfig()) f, _, _, _ := cmdutil.TestFactory(t, defaultConfig())
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil) err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--as", "user"}, f, nil)
@@ -172,6 +184,34 @@ func TestDetail_DryRun_WithArtifactFlags(t *testing.T) {
} }
} }
func TestDetail_HiddenWaitFlags(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
parent := &cobra.Command{Use: "minutes"}
MinutesDetail.Mount(parent, f)
parent.SetOut(stdout)
parent.SetArgs([]string{"+detail", "--help"})
parent.SilenceErrors = true
parent.SilenceUsage = true
if err := parent.Execute(); err != nil {
t.Fatalf("help failed: %v", err)
}
help := stdout.String()
for _, hidden := range []string{"wait-ready", "wait-timeout-seconds", "wait-interval-seconds"} {
if strings.Contains(help, hidden) {
t.Fatalf("hidden flag %q should not appear in help:\n%s", hidden, help)
}
}
stdout.Reset()
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "tok001", "--summary", "--wait-ready",
"--wait-timeout-seconds", "0", "--wait-interval-seconds", "0", "--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("hidden wait flags should parse: %v", err)
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Execute tests with mocked HTTP // Execute tests with mocked HTTP
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -355,6 +395,136 @@ func TestDetail_Execute_MinuteNotFound(t *testing.T) {
} }
} }
func TestDetail_Execute_MetadataProcessing(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokpending"))
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokpending", "--summary", "--as", "user"}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
var pfErr *output.PartialFailureError
if !errors.As(err, &pfErr) {
t.Fatalf("expected *output.PartialFailureError, got %T: %v", err, err)
}
m := firstDetailMinute(t, stdout.Bytes())
if m["status"] != "processing" {
t.Fatalf("status = %v, want processing", m["status"])
}
if m["retryable"] != true {
t.Fatalf("retryable = %v, want true", m["retryable"])
}
if !strings.Contains(fmt.Sprint(m["next_command"]), "minutes +detail --minute-tokens tokpending --summary --wait-ready") {
t.Fatalf("next_command = %v", m["next_command"])
}
}
func TestDetail_Execute_ArtifactsProcessing(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("tokartpending", "note_pending", "Pending Artifacts"))
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokartpending/artifacts"))
err := detailMountAndRun(t, MinutesDetail, []string{"+detail", "--minute-tokens", "tokartpending", "--summary", "--todo", "--as", "user"}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
m := firstDetailMinute(t, stdout.Bytes())
if m["status"] != "processing" {
t.Fatalf("status = %v, want processing", m["status"])
}
if m["title"] != "Pending Artifacts" || m["note_id"] != "note_pending" {
t.Fatalf("metadata should be preserved on artifacts processing, got title=%v note_id=%v", m["title"], m["note_id"])
}
if !strings.Contains(fmt.Sprint(m["next_command"]), "--summary --todo --wait-ready") {
t.Fatalf("next_command = %v", m["next_command"])
}
}
func TestDetail_WaitReady_MetadataEventuallyReady(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitmeta"))
reg.Register(detailMinuteGetStub("tokwaitmeta", "", "Ready Metadata"))
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "tokwaitmeta", "--wait-ready",
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m := firstDetailMinute(t, stdout.Bytes())
if m["title"] != "Ready Metadata" {
t.Fatalf("title = %v, want Ready Metadata", m["title"])
}
if _, ok := m["artifacts"]; ok {
t.Fatal("artifacts should not be fetched without artifact flags")
}
}
func TestDetail_WaitReady_ArtifactsEventuallyReady(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("tokwaitart", "note_wait", "Ready Artifacts"))
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/tokwaitart/artifacts"))
reg.Register(detailArtifactsStub("tokwaitart", ""))
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "tokwaitart", "--summary", "--wait-ready",
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
m := firstDetailMinute(t, stdout.Bytes())
arts, _ := m["artifacts"].(map[string]any)
if arts == nil {
t.Fatal("expected artifacts")
}
if arts["summary"] != "Test summary content" {
t.Fatalf("summary = %v", arts["summary"])
}
}
func TestDetail_WaitReady_TimeoutUsesProcessingResult(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
reg.Register(detailMinuteGetStub("toktimeout", "note_timeout", "Timeout Artifacts"))
reg.Register(detailProcessingStub("/open-apis/minutes/v1/minutes/toktimeout/artifacts"))
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "toktimeout", "--summary", "--wait-ready",
"--wait-timeout-seconds", "1", "--wait-interval-seconds", "2", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
m := firstDetailMinute(t, stdout.Bytes())
if m["status"] != "processing" || m["title"] != "Timeout Artifacts" || m["note_id"] != "note_timeout" {
t.Fatalf("timeout should preserve processing status and metadata, got %+v", m)
}
}
func TestDetail_WaitReady_DoesNotPollNonProcessingErrors(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
var callCount int
reg.Register(&httpmock.Stub{
Method: "GET",
URL: "/open-apis/minutes/v1/minutes/tokmissing",
Body: map[string]interface{}{"code": 2091004, "msg": "not found"},
Reusable: true,
OnMatch: func(req *http.Request) { callCount++ },
})
err := detailMountAndRun(t, MinutesDetail, []string{
"+detail", "--minute-tokens", "tokmissing", "--wait-ready",
"--wait-timeout-seconds", "5", "--wait-interval-seconds", "1", "--as", "user",
}, f, stdout)
if err == nil {
t.Fatal("expected partial failure error")
}
if callCount != 1 {
t.Fatalf("non-processing error should not be retried, callCount=%d", callCount)
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Pure function tests // Pure function tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -378,6 +548,36 @@ func TestValidMinuteTokenDetail(t *testing.T) {
} }
} }
func TestNormalizeMinutesDetailWaitSeconds(t *testing.T) {
timeout, interval := normalizeMinutesDetailWaitSeconds(0, 0)
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
t.Fatalf("normalize(0,0) = (%d,%d), want defaults (%d,%d)", timeout, interval, minutesDetailWaitTimeoutDefault, minutesDetailWaitIntervalDefault)
}
timeout, interval = normalizeMinutesDetailWaitSeconds(-1, -2)
if timeout != minutesDetailWaitTimeoutDefault || interval != minutesDetailWaitIntervalDefault {
t.Fatalf("normalize(negative) = (%d,%d), want defaults", timeout, interval)
}
timeout, interval = normalizeMinutesDetailWaitSeconds(9, 3)
if timeout != 9 || interval != 3 {
t.Fatalf("normalize(9,3) = (%d,%d)", timeout, interval)
}
}
func firstDetailMinute(t *testing.T, raw []byte) map[string]any {
t.Helper()
var resp map[string]any
if err := json.Unmarshal(raw, &resp); err != nil {
t.Fatalf("failed to parse output: %v\n%s", err, string(raw))
}
data, _ := resp["data"].(map[string]any)
minutes, _ := data["minutes"].([]any)
if len(minutes) != 1 {
t.Fatalf("expected 1 minute, got %d in %s", len(minutes), string(raw))
}
m, _ := minutes[0].(map[string]any)
return m
}
// chdirForDetailTest switches cwd to a temp dir for the test. // chdirForDetailTest switches cwd to a temp dir for the test.
func chdirForDetailTest(t *testing.T) string { func chdirForDetailTest(t *testing.T) string {
t.Helper() t.Helper()

View File

@@ -66,31 +66,24 @@ var MinutesSpeakerReplace = common.Shortcut{
}, },
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
minuteToken := strings.TrimSpace(runtime.Str("minute-token")) minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
dr := common.NewDryRunAPI() return common.NewDryRunAPI().
if strings.TrimSpace(runtime.Str("from-speaker-id")) != "" && strings.TrimSpace(runtime.Str("from-user-id")) == "" { PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
dr.GET(minuteTranscriptSpeakerlistPath(minuteToken)).Desc("Resolve --from-speaker-id when it is a display name")
}
return dr.PUT(fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken))).
Body(buildSpeakerReplaceRequestBody(runtime)) Body(buildSpeakerReplaceRequestBody(runtime))
}, },
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
minuteToken := strings.TrimSpace(runtime.Str("minute-token")) minuteToken := strings.TrimSpace(runtime.Str("minute-token"))
fromSpeakerInput := strings.TrimSpace(runtime.Str("from-speaker-id")) fromSpeakerID := strings.TrimSpace(runtime.Str("from-speaker-id"))
fromUserID := strings.TrimSpace(runtime.Str("from-user-id"))
toUserID := strings.TrimSpace(runtime.Str("to-user-id")) toUserID := strings.TrimSpace(runtime.Str("to-user-id"))
fromSpeakerID, fromUserID, err := resolveSpeakerReplaceFrom(runtime, minuteToken) _, err := runtime.CallAPITyped(http.MethodPut,
if err != nil {
return err
}
_, err = runtime.CallAPITyped(http.MethodPut,
fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)), fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speaker", validate.EncodePathSegment(minuteToken)),
map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID)) map[string]interface{}{"user_id_type": "open_id"}, buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID))
if err != nil { if err != nil {
return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID)) return minutesSpeakerReplaceError(err, minuteToken, speakerReplaceSourceLabel(fromSpeakerID, fromUserID))
} }
runtime.OutFormat(buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil) runtime.OutFormat(buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID), nil, nil)
return nil return nil
}, },
} }
@@ -114,26 +107,20 @@ func buildSpeakerReplaceRequestBodyResolved(fromSpeakerID, fromUserID, toUserID
return body return body
} }
func buildSpeakerReplaceOutputData(fromSpeakerInput, minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} { func buildSpeakerReplaceOutputData(minuteToken, fromSpeakerID, fromUserID, toUserID string) map[string]interface{} {
out := map[string]interface{}{ out := map[string]interface{}{
"minute_token": minuteToken, "minute_token": minuteToken,
"to_user_id": toUserID, "to_user_id": toUserID,
} }
if fromSpeakerID != "" { if fromSpeakerID != "" {
out["from_speaker_id"] = fromSpeakerID out["from_speaker_id"] = fromSpeakerID
if fromSpeakerInput != "" && fromSpeakerInput != fromSpeakerID {
out["from_speaker_input"] = fromSpeakerInput
}
} else { } else {
out["from_user_id"] = fromUserID out["from_user_id"] = fromUserID
} }
return out return out
} }
func speakerReplaceSourceLabel(fromSpeakerInput, fromSpeakerID, fromUserID string) string { func speakerReplaceSourceLabel(fromSpeakerID, fromUserID string) string {
if fromSpeakerInput != "" {
return fromSpeakerInput
}
if fromSpeakerID != "" { if fromSpeakerID != "" {
return fromSpeakerID return fromSpeakerID
} }

View File

@@ -153,58 +153,14 @@ func TestMinutesSpeakerReplace_DryRun(t *testing.T) {
} }
} }
func TestMinutesSpeakerReplace_DryRun_ResolveFromSpeakerID(t *testing.T) { func TestMinutesSpeakerReplace_Execute_OpaqueSpeakerIDNoPrefetch(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, _ := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t)
err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken,
"--from-speaker-id", "说话人1",
"--to-user-id", "ou_new_speaker",
"--dry-run", "--as", "user",
}, f, stdout)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "GET") {
t.Errorf("expected GET for internal speaker list, got:\n%s", out)
}
if !strings.Contains(out, "/transcript/speakerlist") {
t.Errorf("expected speakerlist path, got:\n%s", out)
}
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
}
if !strings.Contains(out, "ou_new_speaker") {
t.Errorf("expected to_user_id in body, got:\n%s", out)
}
}
func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir()) t.Setenv("LARKSUITE_CLI_CONFIG_DIR", t.TempDir())
f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig()) f, stdout, _, reg := cmdutil.TestFactory(t, defaultConfig())
warmTokenCache(t) warmTokenCache(t)
reg.Register(&httpmock.Stub{ // Only the PUT is registered on purpose: an opaque speaker_id must be passed
Method: http.MethodGet, // straight through without a second speakerlist call. If the code still
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speakerlist", // prefetched speakerlist, the unregistered GET would fail the request.
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"speakers": []interface{}{
map[string]interface{}{
"speaker_id": "ENCRYPTED_TOKEN_ABC",
"name": "说话人1",
},
},
},
},
})
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
Method: http.MethodPut, Method: http.MethodPut,
URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker", URL: "/open-apis/minutes/v1/minutes/" + minutesSpeakerReplaceTestToken + "/transcript/speaker",
@@ -218,7 +174,7 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
err := mountAndRun(t, MinutesSpeakerReplace, []string{ err := mountAndRun(t, MinutesSpeakerReplace, []string{
"+speaker-replace", "+speaker-replace",
"--minute-token", minutesSpeakerReplaceTestToken, "--minute-token", minutesSpeakerReplaceTestToken,
"--from-speaker-id", "说话人1", "--from-speaker-id", "ENCRYPTED_TOKEN_ABC",
"--to-user-id", "ou_new_speaker", "--to-user-id", "ou_new_speaker",
"--format", "json", "--as", "user", "--format", "json", "--as", "user",
}, f, stdout) }, f, stdout)
@@ -228,21 +184,19 @@ func TestMinutesSpeakerReplace_Execute_ResolveFromSpeakerID(t *testing.T) {
var envelope struct { var envelope struct {
Data struct { Data struct {
MinuteToken string `json:"minute_token"` FromSpeakerID string `json:"from_speaker_id"`
FromSpeakerInput string `json:"from_speaker_input"` ToUserID string `json:"to_user_id"`
FromSpeakerID string `json:"from_speaker_id"`
ToUserID string `json:"to_user_id"`
} `json:"data"` } `json:"data"`
} }
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("unmarshal stdout: %v", err) t.Fatalf("unmarshal stdout: %v", err)
} }
if envelope.Data.FromSpeakerInput != "说话人1" {
t.Errorf("data.from_speaker_input = %q, want 说话人1", envelope.Data.FromSpeakerInput)
}
if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" { if envelope.Data.FromSpeakerID != "ENCRYPTED_TOKEN_ABC" {
t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID) t.Errorf("data.from_speaker_id = %q, want ENCRYPTED_TOKEN_ABC", envelope.Data.FromSpeakerID)
} }
if envelope.Data.ToUserID != "ou_new_speaker" {
t.Errorf("data.to_user_id = %q, want ou_new_speaker", envelope.Data.ToUserID)
}
} }
func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) { func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
@@ -262,8 +216,11 @@ func TestMinutesSpeakerReplace_DryRun_FromSpeakerID(t *testing.T) {
} }
out := stdout.String() out := stdout.String()
if !strings.Contains(out, "GET") { if strings.Contains(out, "/transcript/speakerlist") {
t.Errorf("expected GET for internal speaker list, got:\n%s", out) t.Errorf("opaque speaker_id should not prefetch speakerlist, got:\n%s", out)
}
if !strings.Contains(out, "PUT") {
t.Errorf("expected PUT for speaker replace, got:\n%s", out)
} }
if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") { if !strings.Contains(out, "from_speaker_id") || !strings.Contains(out, "ENCRYPTED_TOKEN_ABC") {
t.Errorf("expected from_speaker_id in body, got:\n%s", out) t.Errorf("expected from_speaker_id in body, got:\n%s", out)

View File

@@ -1,104 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"fmt"
"net/http"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
type minuteSpeaker struct {
SpeakerID string
Name string
}
func minuteTranscriptSpeakerlistPath(minuteToken string) string {
return fmt.Sprintf("/open-apis/minutes/v1/minutes/%s/transcript/speakerlist", validate.EncodePathSegment(minuteToken))
}
func fetchMinuteSpeakers(runtime *common.RuntimeContext, minuteToken string) ([]minuteSpeaker, error) {
data, err := runtime.CallAPITyped(http.MethodGet, minuteTranscriptSpeakerlistPath(minuteToken), nil, nil)
if err != nil {
return nil, err
}
if data == nil {
return nil, nil
}
items := common.GetSlice(data, "speakers")
speakers := make([]minuteSpeaker, 0, len(items))
for _, raw := range items {
item, _ := raw.(map[string]interface{})
if item == nil {
continue
}
id := strings.TrimSpace(common.GetString(item, "speaker_id"))
name := strings.TrimSpace(common.GetString(item, "name"))
if id == "" {
continue
}
speakers = append(speakers, minuteSpeaker{SpeakerID: id, Name: name})
}
return speakers, nil
}
func resolveSpeakerIDByName(speakers []minuteSpeaker, name string) (string, error) {
name = strings.TrimSpace(name)
var matches []minuteSpeaker
for _, s := range speakers {
if s.Name == name {
matches = append(matches, s)
}
}
switch len(matches) {
case 0:
return "", errs.NewValidationError(errs.SubtypeNotFound,
"no speaker named %q in minute transcript", name).
WithParam("--from-speaker-id").
WithHint("Check the speaker name spelling or open the minute to see transcript speaker labels")
case 1:
return matches[0].SpeakerID, nil
default:
ids := make([]string, len(matches))
for i, m := range matches {
ids[i] = m.SpeakerID
}
return "", errs.NewValidationError(errs.SubtypeFailedPrecondition,
"multiple speakers named %q (%d matches); pass the exact --from-speaker-id", name, len(matches)).
WithParam("--from-speaker-id").
WithHint(fmt.Sprintf("Matching speaker_ids: %s. Review each speaker's utterances in the minute, then retry with the exact speaker_id", strings.Join(ids, ", ")))
}
}
// resolveFromSpeakerID resolves --from-speaker-id to an API speaker_id.
// The input may already be an opaque speaker_id, or a display name that requires
// an internal speaker-list fetch.
func resolveFromSpeakerID(runtime *common.RuntimeContext, minuteToken, input string) (string, error) {
input = strings.TrimSpace(input)
speakers, err := fetchMinuteSpeakers(runtime, minuteToken)
if err != nil {
return "", err
}
for _, s := range speakers {
if s.SpeakerID == input {
return input, nil
}
}
return resolveSpeakerIDByName(speakers, input)
}
func resolveSpeakerReplaceFrom(runtime *common.RuntimeContext, minuteToken string) (fromSpeakerID, fromUserID string, err error) {
fromUserID = strings.TrimSpace(runtime.Str("from-user-id"))
if fromUserID != "" {
return "", fromUserID, nil
}
fromSpeakerID, err = resolveFromSpeakerID(runtime, minuteToken, runtime.Str("from-speaker-id"))
return fromSpeakerID, "", err
}

View File

@@ -1,45 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package minutes
import (
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
)
func TestResolveSpeakerIDByName(t *testing.T) {
speakers := []minuteSpeaker{
{SpeakerID: "id_a", Name: "Alice"},
{SpeakerID: "id_b", Name: "Bob"},
{SpeakerID: "id_c", Name: "Alice"},
}
id, err := resolveSpeakerIDByName(speakers, "Bob")
if err != nil || id != "id_b" {
t.Fatalf("resolve Bob: id=%q err=%v", id, err)
}
_, err = resolveSpeakerIDByName(speakers, "Carol")
if err == nil {
t.Fatal("expected not found error")
}
var ve *errs.ValidationError
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeNotFound {
t.Fatalf("want not-found validation error, got %T: %v", err, err)
}
_, err = resolveSpeakerIDByName(speakers, "Alice")
if err == nil {
t.Fatal("expected duplicate name error")
}
if !errors.As(err, &ve) || ve.Subtype != errs.SubtypeFailedPrecondition {
t.Fatalf("want failed-precondition validation error, got %T: %v", err, err)
}
if !strings.Contains(ve.Hint, "id_a") || !strings.Contains(ve.Hint, "id_c") {
t.Errorf("hint should list matching speaker_ids, got: %s", ve.Hint)
}
}

View File

@@ -5,6 +5,8 @@ package minutes
import ( import (
"context" "context"
"net/url"
"strings"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate" "github.com/larksuite/cli/internal/validate"
@@ -65,8 +67,25 @@ var MinutesUpload = common.Shortcut{
outData := map[string]interface{}{ outData := map[string]interface{}{
"minute_url": minuteURL, "minute_url": minuteURL,
} }
if minuteToken := extractUploadedMinuteToken(minuteURL); minuteToken != "" {
outData["minute_token"] = minuteToken
}
runtime.OutFormat(outData, nil, nil) runtime.OutFormat(outData, nil, nil)
return nil return nil
}, },
} }
func extractUploadedMinuteToken(minuteURL string) string {
u, err := url.Parse(minuteURL)
if err != nil {
return ""
}
parts := strings.Split(strings.TrimRight(u.Path, "/"), "/")
for i, part := range parts {
if part == "minutes" && i+1 < len(parts) {
return parts[i+1]
}
}
return ""
}

View File

@@ -143,4 +143,28 @@ func TestMinutesUpload_Execute(t *testing.T) {
if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" { if dataMap["minute_url"] != "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c" {
t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"]) t.Errorf("expected minute_url https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_url"])
} }
if dataMap["minute_token"] != "obcnq3b9jl72l83w4f149w9c" {
t.Errorf("expected minute_token obcnq3b9jl72l83w4f149w9c, got %v", dataMap["minute_token"])
}
}
func TestExtractUploadedMinuteToken(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{name: "standard", url: "https://sample.feishu.cn/minutes/obcnq3b9jl72l83w4f149w9c", want: "obcnq3b9jl72l83w4f149w9c"},
{name: "query", url: "https://sample.feishu.cn/minutes/obcn123?from=upload", want: "obcn123"},
{name: "trailing slash", url: "https://sample.feishu.cn/minutes/obcn123/", want: "obcn123"},
{name: "invalid", url: "://bad", want: ""},
{name: "no minutes path", url: "https://sample.feishu.cn/docx/abc", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := extractUploadedMinuteToken(tt.url); got != tt.want {
t.Fatalf("extractUploadedMinuteToken(%q) = %q, want %q", tt.url, got, tt.want)
}
})
}
} }

View File

@@ -58,45 +58,9 @@ func parseBatchCreateInput(input string) ([]batchCreateObjective, error) {
return objectives, nil return objectives, nil
} }
// buildContentBlock converts text and mentions to a ContentBlock.
func buildContentBlock(text string, mentions []string) *ContentBlock {
elements := make([]ContentParagraphElement, 0, len(mentions)+1)
// Add text element
textElem := ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: &text,
},
}
elements = append(elements, textElem)
// Add mention elements
for _, mention := range mentions {
mentionElem := ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: &mention,
},
}
elements = append(elements, mentionElem)
}
return &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: elements,
},
},
},
}
}
// createObjective calls the API to create an objective. // createObjective calls the API to create an objective.
func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) { func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleID, userIDType string, obj batchCreateObjective) (string, error) {
content := buildContentBlock(obj.Text, obj.Mention) content := BuildContentBlock(obj.Text, obj.Mention)
body := map[string]interface{}{ body := map[string]interface{}{
"content": content, "content": content,
} }
@@ -120,7 +84,7 @@ func createObjective(ctx context.Context, runtime *common.RuntimeContext, cycleI
// createKR calls the API to create a key result. // createKR calls the API to create a key result.
func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) { func createKR(ctx context.Context, runtime *common.RuntimeContext, objectiveID, userIDType string, kr batchCreateKR) (string, error) {
content := buildContentBlock(kr.Text, kr.Mention) content := BuildContentBlock(kr.Text, kr.Mention)
body := map[string]interface{}{ body := map[string]interface{}{
"content": content, "content": content,
} }
@@ -224,7 +188,7 @@ var OKRBatchCreate = common.Shortcut{
for i, obj := range objectives { for i, obj := range objectives {
// Objective creation // Objective creation
objContent := buildContentBlock(obj.Text, obj.Mention) objContent := BuildContentBlock(obj.Text, obj.Mention)
objBody := map[string]interface{}{ objBody := map[string]interface{}{
"content": objContent, "content": objContent,
} }
@@ -241,7 +205,7 @@ var OKRBatchCreate = common.Shortcut{
// KR creations // KR creations
for j, kr := range obj.KRs { for j, kr := range obj.KRs {
krContent := buildContentBlock(kr.Text, kr.Mention) krContent := BuildContentBlock(kr.Text, kr.Mention)
krBody := map[string]interface{}{ krBody := map[string]interface{}{
"content": krContent, "content": krContent,
} }

View File

@@ -557,7 +557,7 @@ func TestParseBatchCreateInput_Valid(t *testing.T) {
func TestBuildContentBlock(t *testing.T) { func TestBuildContentBlock(t *testing.T) {
t.Parallel() t.Parallel()
cb := buildContentBlock("Test text", []string{"ou_123", "ou_456"}) cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
if cb == nil { if cb == nil {
t.Fatal("expected non-nil ContentBlock") t.Fatal("expected non-nil ContentBlock")
} }

View File

@@ -29,15 +29,10 @@ type RespCategory struct {
// RespCycle 周期 // RespCycle 周期
type RespCycle struct { type RespCycle struct {
ID string `json:"id"` ID string `json:"id"`
CreateTime string `json:"create_time"` StartTime string `json:"start_time"`
UpdateTime string `json:"update_time"` EndTime string `json:"end_time"`
TenantCycleID string `json:"tenant_cycle_id"` CycleStatus *string `json:"cycle_status,omitempty"`
Owner RespOwner `json:"owner"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
CycleStatus *string `json:"cycle_status,omitempty"`
Score *float64 `json:"score,omitempty"`
} }
// RespIndicator 指标 // RespIndicator 指标
@@ -152,3 +147,145 @@ type RespProgress struct {
Content *string `json:"content,omitempty"` Content *string `json:"content,omitempty"`
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"` ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
} }
// ========== Simple-style response types (semi-plain text format) ==========
// RespKeyResultSimple is KeyResult response with SemiPlainContent instead of ContentBlock JSON string.
type RespKeyResultSimple struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Owner RespOwner `json:"owner"`
ObjectiveID string `json:"objective_id"`
Position *int32 `json:"position,omitempty"`
Content *SemiPlainContent `json:"content,omitempty"`
Score *float64 `json:"score,omitempty"`
Weight *float64 `json:"weight,omitempty"`
Deadline *string `json:"deadline,omitempty"`
}
// RespObjectiveSimple is Objective response with SemiPlainContent instead of ContentBlock JSON string.
type RespObjectiveSimple struct {
ID string `json:"id"`
CreateTime string `json:"create_time"`
UpdateTime string `json:"update_time"`
Owner RespOwner `json:"owner"`
CycleID string `json:"cycle_id"`
Position *int32 `json:"position,omitempty"`
Content *SemiPlainContent `json:"content,omitempty"`
Score *float64 `json:"score,omitempty"`
Notes *SemiPlainContent `json:"notes,omitempty"`
Weight *float64 `json:"weight,omitempty"`
Deadline *string `json:"deadline,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
KeyResults []RespKeyResultSimple `json:"key_results,omitempty"`
}
// RespProgressSimple is Progress response with SemiPlainContent instead of ContentBlock JSON string.
type RespProgressSimple struct {
ID string `json:"progress_id"`
ModifyTime string `json:"modify_time"`
CreateTime *string `json:"create_time,omitempty"`
Content *SemiPlainContent `json:"content,omitempty"`
ProgressRate *RespProgressRate `json:"progress_rate,omitempty"`
}
// ToSimple converts KeyResult to RespKeyResultSimple.
func (k *KeyResult) ToSimple() *RespKeyResultSimple {
if k == nil {
return nil
}
result := &RespKeyResultSimple{
ID: k.ID,
CreateTime: formatTimestamp(k.CreateTime),
UpdateTime: formatTimestamp(k.UpdateTime),
Owner: *k.Owner.ToResp(),
ObjectiveID: k.ObjectiveID,
Position: k.Position,
Score: k.Score,
Weight: k.Weight,
}
if k.Deadline != nil {
d := formatTimestamp(*k.Deadline)
result.Deadline = &d
}
result.Content = k.Content.ToSemiPlain()
return result
}
// ToSimple converts Objective to RespObjectiveSimple.
func (o *Objective) ToSimple() *RespObjectiveSimple {
if o == nil {
return nil
}
result := &RespObjectiveSimple{
ID: o.ID,
CreateTime: formatTimestamp(o.CreateTime),
UpdateTime: formatTimestamp(o.UpdateTime),
Owner: *o.Owner.ToResp(),
CycleID: o.CycleID,
Position: o.Position,
Score: o.Score,
Weight: o.Weight,
CategoryID: o.CategoryID,
}
if o.Deadline != nil {
d := formatTimestamp(*o.Deadline)
result.Deadline = &d
}
result.Content = o.Content.ToSemiPlain()
result.Notes = o.Notes.ToSemiPlain()
return result
}
// ToSimple converts ProgressV1 to RespProgressSimple.
func (p *ProgressV1) ToSimple() *RespProgressSimple {
if p == nil {
return nil
}
resp := &RespProgressSimple{
ID: p.ID,
ModifyTime: formatTimestamp(p.ModifyTime),
}
if p.ProgressRate != nil {
resp.ProgressRate = &RespProgressRate{
Percent: p.ProgressRate.Percent,
}
if p.ProgressRate.Status != nil {
s := ProgressStatus(*p.ProgressRate.Status).String()
if s != "" {
resp.ProgressRate.Status = &s
}
}
}
if p.Content != nil {
resp.Content = p.Content.ToV2().ToSemiPlain()
}
return resp
}
// ToSimple converts Progress to RespProgressSimple.
func (p *Progress) ToSimple() *RespProgressSimple {
if p == nil {
return nil
}
createTime := formatTimestamp(p.CreateTime)
resp := &RespProgressSimple{
ID: p.ID,
ModifyTime: formatTimestamp(p.UpdateTime),
CreateTime: &createTime,
}
if p.ProgressRate != nil {
resp.ProgressRate = &RespProgressRate{
Percent: p.ProgressRate.ProgressPercent,
}
if p.ProgressRate.ProgressStatus != nil {
s := ProgressStatus(*p.ProgressRate.ProgressStatus).String()
if s != "" {
resp.ProgressRate.Status = &s
}
}
}
resp.Content = p.Content.ToSemiPlain()
return resp
}

View File

@@ -26,6 +26,7 @@ var OKRCycleDetail = common.Shortcut{
HasFormat: true, HasFormat: true,
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true}, {Name: "cycle-id", Desc: "OKR cycle id (int64)", Required: true},
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id") cycleID := runtime.Str("cycle-id")
@@ -35,6 +36,10 @@ var OKRCycleDetail = common.Shortcut{
if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 { if id, err := strconv.ParseInt(cycleID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id") return errs.NewValidationError(errs.SubtypeInvalidArgument, "--cycle-id must be a positive int64").WithParam("--cycle-id")
} }
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
return nil return nil
}, },
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -50,6 +55,7 @@ var OKRCycleDetail = common.Shortcut{
}, },
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
cycleID := runtime.Str("cycle-id") cycleID := runtime.Str("cycle-id")
style := runtime.Str("style")
// Paginate objectives under the cycle. // Paginate objectives under the cycle.
queryParams := map[string]interface{}{"page_size": "100"} queryParams := map[string]interface{}{"page_size": "100"}
@@ -96,85 +102,106 @@ var OKRCycleDetail = common.Shortcut{
} }
// For each objective, paginate key results and convert to response format. // For each objective, paginate key results and convert to response format.
respObjectives := make([]*RespObjective, 0, len(objectives)) if style == "simple" {
for i := range objectives { respObjectives := make([]*RespObjectiveSimple, 0, len(objectives))
if err := ctx.Err(); err != nil { for i := range objectives {
return err
}
obj := &objectives[i]
krQuery := map[string]interface{}{"page_size": "100"}
var keyResults []KeyResult
krPage := 0
for {
if err := ctx.Err(); err != nil { if err := ctx.Err(); err != nil {
return err return err
} }
if krPage > 0 { obj := &objectives[i]
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(500 * time.Millisecond):
}
}
krPage++
path := fmt.Sprintf("/open-apis/okr/v2/objectives/%s/key_results", obj.ID) keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
data, err := runtime.CallAPITyped("GET", path, krQuery, nil)
if err != nil { if err != nil {
return err return err
} }
itemsRaw, _ := data["items"].([]interface{}) respObj := obj.ToSimple()
for _, item := range itemsRaw { if respObj == nil {
raw, err := json.Marshal(item) continue
if err != nil { }
continue respKRs := make([]RespKeyResultSimple, 0, len(keyResults))
for j := range keyResults {
if r := keyResults[j].ToSimple(); r != nil {
respKRs = append(respKRs, *r)
} }
var kr KeyResult }
if err := json.Unmarshal(raw, &kr); err != nil { respObj.KeyResults = respKRs
continue respObjectives = append(respObjectives, respObj)
}
result := map[string]interface{}{
"cycle_id": cycleID,
"objectives": respObjectives,
"total": len(respObjectives),
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
for _, o := range respObjectives {
contentText := ""
if o.Content != nil {
contentText = o.Content.Text
} }
keyResults = append(keyResults, kr) notesText := ""
if o.Notes != nil {
notesText = o.Notes.Text
}
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, contentText, notesText, ptrFloat64(o.Score), ptrFloat64(o.Weight))
for _, kr := range o.KeyResults {
krText := ""
if kr.Content != nil {
krText = kr.Content.Text
}
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, krText, ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
}
}
})
} else {
// richtext mode
respObjectives := make([]*RespObjective, 0, len(objectives))
for i := range objectives {
if err := ctx.Err(); err != nil {
return err
}
obj := &objectives[i]
keyResults, err := fetchKeyResults(ctx, runtime, obj.ID)
if err != nil {
return err
} }
hasMore, pageToken := common.PaginationMeta(data) respObj := obj.ToResp()
if !hasMore || pageToken == "" { if respObj == nil {
break continue
} }
krQuery["page_token"] = pageToken respKRs := make([]RespKeyResult, 0, len(keyResults))
for j := range keyResults {
if r := keyResults[j].ToResp(); r != nil {
respKRs = append(respKRs, *r)
}
}
respObj.KeyResults = respKRs
respObjectives = append(respObjectives, respObj)
} }
respObj := obj.ToResp() result := map[string]interface{}{
if respObj == nil { "cycle_id": cycleID,
continue "objectives": respObjectives,
"total": len(respObjectives),
"style": style,
} }
respKRs := make([]RespKeyResult, 0, len(keyResults))
for j := range keyResults { runtime.OutFormat(result, nil, func(w io.Writer) {
if r := keyResults[j].ToResp(); r != nil { fmt.Fprintf(w, "Cycle %s: %d objective(s) (style: %s)\n", cycleID, len(respObjectives), style)
respKRs = append(respKRs, *r) for _, o := range respObjectives {
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
for _, kr := range o.KeyResults {
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
}
} }
} })
respObj.KeyResults = respKRs
respObjectives = append(respObjectives, respObj)
} }
result := map[string]interface{}{
"cycle_id": cycleID,
"objectives": respObjectives,
"total": len(respObjectives),
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Cycle %s: %d objective(s)\n", cycleID, len(respObjectives))
for _, o := range respObjectives {
fmt.Fprintf(w, "Objective [%s]: %s \n Notes: %s \n score=%.2f weight=%.2f\n", o.ID, ptrStr(o.Content), ptrStr(o.Notes), ptrFloat64(o.Score), ptrFloat64(o.Weight))
for _, kr := range o.KeyResults {
fmt.Fprintf(w, " - KR [%s]: %s \n score=%.2f weight=%.2f\n", kr.ID, ptrStr(kr.Content), ptrFloat64(kr.Score), ptrFloat64(kr.Weight))
}
}
})
return nil return nil
}, },
} }

View File

@@ -46,12 +46,38 @@ func cycleOverlaps(cycle *Cycle, rangeStart, rangeEnd time.Time) bool {
if err1 != nil || err2 != nil { if err1 != nil || err2 != nil {
return false return false
} }
cycleStart := time.UnixMilli(startMs) cycleStart := time.UnixMilli(startMs).UTC()
cycleEnd := time.UnixMilli(endMs) cycleEnd := time.UnixMilli(endMs).UTC()
// Two ranges overlap iff one starts before the other ends // Two ranges overlap iff one starts before the other ends
return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart) return !cycleStart.After(rangeEnd) && !cycleEnd.Before(rangeStart)
} }
// isCurrentActiveCycle checks whether a cycle is currently active:
// - current time is within the cycle's start and end time
// - cycle status is default (0) or normal (1)
func isCurrentActiveCycle(cycle *Cycle, now time.Time) bool {
startMs, err1 := strconv.ParseInt(cycle.StartTime, 10, 64)
endMs, err2 := strconv.ParseInt(cycle.EndTime, 10, 64)
if err1 != nil || err2 != nil {
return false
}
cycleStart := time.UnixMilli(startMs).UTC()
cycleEnd := time.UnixMilli(endMs).UTC()
nowUTC := now.UTC()
// Check time range: now must be >= start and <= end
if nowUTC.Before(cycleStart) || nowUTC.After(cycleEnd) {
return false
}
// Check status: must be default or normal
if cycle.CycleStatus == nil {
return false
}
status := *cycle.CycleStatus
return status == CycleStatusDefault || status == CycleStatusNormal
}
var OKRListCycles = common.Shortcut{ var OKRListCycles = common.Shortcut{
Service: "okr", Service: "okr",
Command: "+cycle-list", Command: "+cycle-list",
@@ -175,14 +201,30 @@ var OKRListCycles = common.Shortcut{
respCycles = append(respCycles, filtered[i].ToResp()) respCycles = append(respCycles, filtered[i].ToResp())
} }
// Filter current active cycles
now := time.Now()
currentActiveCycles := make([]*RespCycle, 0)
for i := range filtered {
if isCurrentActiveCycle(&filtered[i], now) {
currentActiveCycles = append(currentActiveCycles, filtered[i].ToResp())
}
}
runtime.OutFormat(map[string]interface{}{ runtime.OutFormat(map[string]interface{}{
"cycles": respCycles, "cycles": respCycles,
"total": len(respCycles), "total": len(respCycles),
"current_active_cycles": currentActiveCycles,
}, nil, func(w io.Writer) { }, nil, func(w io.Writer) {
fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles)) fmt.Fprintf(w, "Found %d cycle(s)\n", len(respCycles))
for _, c := range respCycles { for _, c := range respCycles {
fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus)) fmt.Fprintf(w, " [%s] %s ~ %s (status: %s)\n", c.ID, c.StartTime, c.EndTime, ptrStr(c.CycleStatus))
} }
if len(currentActiveCycles) > 0 {
fmt.Fprintf(w, "\nCurrent active cycle(s):\n")
for _, c := range currentActiveCycles {
fmt.Fprintf(w, " [%s] %s ~ %s\n", c.ID, c.StartTime, c.EndTime)
}
}
}) })
return nil return nil
}, },

View File

@@ -5,8 +5,10 @@ package okr
import ( import (
"bytes" "bytes"
"strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -260,11 +262,156 @@ func TestCycleListExecute_NoCycles(t *testing.T) {
if len(cycles) != 0 { if len(cycles) != 0 {
t.Fatalf("cycles = %v, want empty", cycles) t.Fatalf("cycles = %v, want empty", cycles)
} }
// Assert current_active_cycles field exists and is a slice
rawCurrentActive, ok := data["current_active_cycles"]
if !ok {
t.Fatal("current_active_cycles field is missing from response")
}
currentActive, ok := rawCurrentActive.([]interface{})
if !ok {
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
}
if len(currentActive) != 0 {
t.Fatalf("current_active_cycles = %v, want empty", currentActive)
}
}
// --- isCurrentActiveCycle unit tests ---
func TestIsCurrentActiveCycle(t *testing.T) {
t.Parallel()
now := time.Date(2026, 6, 29, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
cycle *Cycle
expected bool
}{
{
name: "active cycle with normal status",
cycle: &Cycle{
ID: "c1",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31 23:59:59
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: true,
},
{
name: "active cycle with default status",
cycle: &Cycle{
ID: "c2",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusDefault.Ptr(),
},
expected: true,
},
{
name: "cycle with invalid status",
cycle: &Cycle{
ID: "c3",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusInvalid.Ptr(),
},
expected: false,
},
{
name: "cycle with hidden status",
cycle: &Cycle{
ID: "c4",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusHidden.Ptr(),
},
expected: false,
},
{
name: "past cycle",
cycle: &Cycle{
ID: "c5",
StartTime: "1704067200000", // 2024-01-01
EndTime: "1719791999999", // 2024-06-30
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: false,
},
{
name: "future cycle",
cycle: &Cycle{
ID: "c6",
StartTime: "1830297600000", // 2028-01-01
EndTime: "1861833599999", // 2028-12-31
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: false,
},
{
name: "nil cycle status",
cycle: &Cycle{
ID: "c7",
StartTime: "1767225600000", // 2026-01-01
EndTime: "1798761599999", // 2026-12-31
CycleStatus: nil,
},
expected: false,
},
{
name: "invalid start time",
cycle: &Cycle{
ID: "c8",
StartTime: "invalid",
EndTime: "1798761599999", // 2026-12-31
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: false,
},
{
name: "exact start time boundary",
cycle: &Cycle{
ID: "c9",
StartTime: "1782734400000", // 2026-06-29 12:00:00 UTC
EndTime: "1798761599000", // 2026-12-31 23:59:59 UTC
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: true,
},
{
name: "exact end time boundary",
cycle: &Cycle{
ID: "c10",
StartTime: "1767225600000", // 2026-01-01 00:00:00 UTC
EndTime: "1782734400000", // 2026-06-29 12:00:00 UTC
CycleStatus: CycleStatusNormal.Ptr(),
},
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isCurrentActiveCycle(tt.cycle, now)
if result != tt.expected {
t.Fatalf("isCurrentActiveCycle() = %v, want %v", result, tt.expected)
}
})
}
} }
func TestCycleListExecute_WithCycles(t *testing.T) { func TestCycleListExecute_WithCycles(t *testing.T) {
t.Parallel() t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t)) f, stdout, _, reg := cmdutil.TestFactory(t, cycleListTestConfig(t))
// Calculate timestamps relative to now to avoid test expiration
now := time.Now().UTC()
// Active cycle: 6 months before to 6 months after now
activeStartMs := now.AddDate(0, -6, 0).UnixMilli()
activeEndMs := now.AddDate(0, 6, 0).UnixMilli()
// Past cycle: 2 years before to 1.5 years before now
pastStartMs := now.AddDate(-2, 0, 0).UnixMilli()
pastEndMs := now.AddDate(-1, -6, 0).UnixMilli()
reg.Register(&httpmock.Stub{ reg.Register(&httpmock.Stub{
Method: "GET", Method: "GET",
URL: "/open-apis/okr/v2/cycles", URL: "/open-apis/okr/v2/cycles",
@@ -274,19 +421,19 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
"data": map[string]interface{}{ "data": map[string]interface{}{
"items": []interface{}{ "items": []interface{}{
map[string]interface{}{ map[string]interface{}{
"id": "cycle-1", "id": "cycle-active",
"start_time": "1735689600000", "start_time": strconv.FormatInt(activeStartMs, 10),
"end_time": "1751318400000", "end_time": strconv.FormatInt(activeEndMs, 10),
"cycle_status": 1, "cycle_status": 1, // normal
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"}, "owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"tenant_cycle_id": "tc-1", "tenant_cycle_id": "tc-1",
"score": 0.75, "score": 0.75,
}, },
map[string]interface{}{ map[string]interface{}{
"id": "cycle-2", "id": "cycle-past",
"start_time": "1704067200000", "start_time": strconv.FormatInt(pastStartMs, 10),
"end_time": "1719792000000", "end_time": strconv.FormatInt(pastEndMs, 10),
"cycle_status": 2, "cycle_status": 2, // invalid
"owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"}, "owner": map[string]interface{}{"owner_type": "user", "user_id": "ou-1"},
"tenant_cycle_id": "tc-2", "tenant_cycle_id": "tc-2",
"score": 0.5, "score": 0.5,
@@ -311,6 +458,46 @@ func TestCycleListExecute_WithCycles(t *testing.T) {
if int(total) != 2 { if int(total) != 2 {
t.Fatalf("total = %v, want 2", total) t.Fatalf("total = %v, want 2", total)
} }
// Check current_active_cycles - should only contain cycle-active
rawCurrentActive, ok := data["current_active_cycles"]
if !ok {
t.Fatal("current_active_cycles field is missing from response")
}
currentActive, ok := rawCurrentActive.([]interface{})
if !ok {
t.Fatalf("current_active_cycles is not a slice, got %T", rawCurrentActive)
}
if len(currentActive) != 1 {
t.Fatalf("current_active_cycles count = %d, want 1", len(currentActive))
}
activeCycle, ok := currentActive[0].(map[string]interface{})
if !ok {
t.Fatalf("current_active_cycles[0] is not a map, got %T", currentActive[0])
}
if activeCycle["id"] != "cycle-active" {
t.Fatalf("current_active_cycles[0].id = %v, want cycle-active", activeCycle["id"])
}
// Verify removed fields are not present in the response
for _, c := range cycles {
cycleMap, _ := c.(map[string]interface{})
if _, ok := cycleMap["create_time"]; ok {
t.Fatal("create_time should not be present in response")
}
if _, ok := cycleMap["update_time"]; ok {
t.Fatal("update_time should not be present in response")
}
if _, ok := cycleMap["tenant_cycle_id"]; ok {
t.Fatal("tenant_cycle_id should not be present in response")
}
if _, ok := cycleMap["owner"]; ok {
t.Fatal("owner should not be present in response")
}
if _, ok := cycleMap["score"]; ok {
t.Fatal("score should not be present in response")
}
}
} }
func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) { func TestCycleListExecute_WithTimeRangeFilter(t *testing.T) {

View File

@@ -5,7 +5,9 @@ package okr
import ( import (
"encoding/json" "encoding/json"
"regexp"
"strconv" "strconv"
"strings"
"time" "time"
) )
@@ -261,14 +263,9 @@ func (c *Cycle) ToResp() *RespCycle {
return nil return nil
} }
resp := &RespCycle{ resp := &RespCycle{
ID: c.ID, ID: c.ID,
CreateTime: formatTimestamp(c.CreateTime), StartTime: formatTimestamp(c.StartTime),
UpdateTime: formatTimestamp(c.UpdateTime), EndTime: formatTimestamp(c.EndTime),
TenantCycleID: c.TenantCycleID,
Owner: *c.Owner.ToResp(),
StartTime: formatTimestamp(c.StartTime),
EndTime: formatTimestamp(c.EndTime),
Score: c.Score,
} }
if c.CycleStatus != nil { if c.CycleStatus != nil {
s := c.CycleStatus.ToString() s := c.CycleStatus.ToString()
@@ -733,6 +730,131 @@ func (p *ContentPersonV1) ToV2() *ContentMention {
} }
} }
// ========== SemiPlainContent (半纯文本格式) ==========
// Regex patterns for semi-plain text processing (pre-compiled for performance).
var (
placeholderRE = regexp.MustCompile(`\s*@\{[^}]+\}\s*`)
multiSpaceRE = regexp.MustCompile(`\s+`)
)
// SemiPlainDoc represents a document link in semi-plain content.
type SemiPlainDoc struct {
Title string `json:"title"`
URL string `json:"url"`
}
// SemiPlainContent is a simplified, lossy representation of ContentBlock.
// It contains plain text, mentions, docs, and images without rich formatting or position info.
type SemiPlainContent struct {
Text string `json:"text"`
Mention []string `json:"mention,omitempty"`
Docs []SemiPlainDoc `json:"docs,omitempty"`
Images []string `json:"images,omitempty"`
}
// ToSemiPlain converts ContentBlock to SemiPlainContent (lossy conversion).
// Position information and formatting are discarded; only text, mentions, docs, and images are extracted.
func (c *ContentBlock) ToSemiPlain() *SemiPlainContent {
if c == nil {
return nil
}
result := &SemiPlainContent{}
var textParts []string
for _, block := range c.Blocks {
if block.Paragraph != nil {
for _, elem := range block.Paragraph.Elements {
switch {
case elem.TextRun != nil && elem.TextRun.Text != nil:
textParts = append(textParts, *elem.TextRun.Text)
case elem.Mention != nil && elem.Mention.UserID != nil:
textParts = append(textParts, " @{"+*elem.Mention.UserID+"} ")
result.Mention = append(result.Mention, *elem.Mention.UserID)
case elem.DocsLink != nil:
doc := SemiPlainDoc{}
if elem.DocsLink.Title != nil {
doc.Title = *elem.DocsLink.Title
}
if elem.DocsLink.URL != nil {
doc.URL = *elem.DocsLink.URL
}
result.Docs = append(result.Docs, doc)
}
}
}
if block.Gallery != nil {
for _, img := range block.Gallery.Images {
if img.Src != nil {
result.Images = append(result.Images, *img.Src)
}
}
}
}
result.Text = strings.Join(textParts, "")
return result
}
// ToContentBlock converts SemiPlainContent to ContentBlock.
// Text and mentions are placed in a single paragraph (text first, then mentions).
// Docs and images are NOT converted (input semi-plain format only supports text+mention).
func (s *SemiPlainContent) ToContentBlock() *ContentBlock {
if s == nil {
return nil
}
elements := make([]ContentParagraphElement, 0, len(s.Mention)+1)
// Strip @{userID} placeholders from text to avoid duplicate mentions
// (these placeholders are only for readability in the output format)
strippedText := placeholderRE.ReplaceAllString(s.Text, " ")
// Collapse multiple spaces and trim
strippedText = multiSpaceRE.ReplaceAllString(strippedText, " ")
strippedText = strings.TrimSpace(strippedText)
// Add text element if stripped text is not empty
if strippedText != "" {
text := strippedText
elements = append(elements, ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: &text,
},
})
}
// Add mention elements
for _, mention := range s.Mention {
m := mention
elements = append(elements, ContentParagraphElement{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: &m,
},
})
}
return &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: elements,
},
},
},
}
}
// BuildContentBlock converts text and mentions to a ContentBlock.
// This is a convenience wrapper around SemiPlainContent.ToContentBlock().
func BuildContentBlock(text string, mentions []string) *ContentBlock {
return (&SemiPlainContent{
Text: text,
Mention: mentions,
}).ToContentBlock()
}
// ProgressRateV1 进度率 // ProgressRateV1 进度率
type ProgressRateV1 struct { type ProgressRateV1 struct {
Percent *float64 `json:"percent,omitempty"` Percent *float64 `json:"percent,omitempty"`

View File

@@ -57,7 +57,9 @@ func TestToRespMethods(t *testing.T) {
convey.So(resp, convey.ShouldNotBeNil) convey.So(resp, convey.ShouldNotBeNil)
convey.So(resp.ID, convey.ShouldEqual, "cycle-id") convey.So(resp.ID, convey.ShouldEqual, "cycle-id")
convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal") convey.So(*resp.CycleStatus, convey.ShouldEqual, "normal")
convey.So(*resp.Score, convey.ShouldEqual, 0.75) // Verify removed fields are not present in RespCycle
convey.So(resp.StartTime, convey.ShouldNotBeEmpty)
convey.So(resp.EndTime, convey.ShouldNotBeEmpty)
}) })
convey.Convey("Objective", func() { convey.Convey("Objective", func() {
@@ -518,5 +520,449 @@ func float64Ptr(v float64) *float64 { return &v }
// boolPtr returns a pointer to the given bool value. // boolPtr returns a pointer to the given bool value.
func boolPtr(v bool) *bool { return &v } func boolPtr(v bool) *bool { return &v }
// ========== SemiPlainContent Conversion Tests ==========
func TestContentBlockToSemiPlain_TextOnly(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Hello world"),
},
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
if sp.Text != "Hello world" {
t.Fatalf("expected text 'Hello world', got '%s'", sp.Text)
}
if len(sp.Mention) != 0 {
t.Fatalf("expected 0 mentions, got %d", len(sp.Mention))
}
if len(sp.Docs) != 0 {
t.Fatalf("expected 0 docs, got %d", len(sp.Docs))
}
if len(sp.Images) != 0 {
t.Fatalf("expected 0 images, got %d", len(sp.Images))
}
}
func TestContentBlockToSemiPlain_WithMention(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Hello "),
},
},
{
ParagraphElementType: ParagraphElementTypeMention.Ptr(),
Mention: &ContentMention{
UserID: strPtr("ou_123"),
},
},
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr(", how are you?"),
},
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
// Text includes @{userID} placeholder to preserve positional context
if sp.Text != "Hello @{ou_123} , how are you?" {
t.Fatalf("expected text 'Hello @{ou_123} , how are you?', got '%s'", sp.Text)
}
if len(sp.Mention) != 1 || sp.Mention[0] != "ou_123" {
t.Fatalf("expected mention [ou_123], got %v", sp.Mention)
}
}
func TestContentBlockToSemiPlain_WithDocsAndImages(t *testing.T) {
t.Parallel()
cb := &ContentBlock{
Blocks: []ContentBlockElement{
{
BlockElementType: BlockElementTypeParagraph.Ptr(),
Paragraph: &ContentParagraph{
Elements: []ContentParagraphElement{
{
ParagraphElementType: ParagraphElementTypeTextRun.Ptr(),
TextRun: &ContentTextRun{
Text: strPtr("Check out this doc: "),
},
},
{
ParagraphElementType: ParagraphElementTypeDocsLink.Ptr(),
DocsLink: &ContentDocsLink{
Title: strPtr("Design Doc"),
URL: strPtr("https://example.feishu.cn/docx/xxx"),
},
},
},
},
},
{
BlockElementType: BlockElementTypeGallery.Ptr(),
Gallery: &ContentGallery{
Images: []ContentImageItem{
{
Src: strPtr("https://example.com/img1.png"),
},
{
Src: strPtr("https://example.com/img2.png"),
},
},
},
},
},
}
sp := cb.ToSemiPlain()
if sp == nil {
t.Fatal("expected non-nil SemiPlainContent")
}
if sp.Text != "Check out this doc: " {
t.Fatalf("unexpected text: '%s'", sp.Text)
}
if len(sp.Docs) != 1 {
t.Fatalf("expected 1 doc, got %d", len(sp.Docs))
}
if sp.Docs[0].Title != "Design Doc" || sp.Docs[0].URL != "https://example.feishu.cn/docx/xxx" {
t.Fatalf("unexpected doc: %+v", sp.Docs[0])
}
if len(sp.Images) != 2 {
t.Fatalf("expected 2 images, got %d", len(sp.Images))
}
if sp.Images[0] != "https://example.com/img1.png" || sp.Images[1] != "https://example.com/img2.png" {
t.Fatalf("unexpected images: %v", sp.Images)
}
}
func TestContentBlockToSemiPlain_Nil(t *testing.T) {
t.Parallel()
var cb *ContentBlock
sp := cb.ToSemiPlain()
if sp != nil {
t.Fatal("expected nil SemiPlainContent for nil ContentBlock")
}
}
func TestSemiPlainContentToContentBlock_TextOnly(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Hello world",
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
if len(cb.Blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
}
block := cb.Blocks[0]
if block.BlockElementType == nil || *block.BlockElementType != BlockElementTypeParagraph {
t.Fatal("expected paragraph block")
}
if block.Paragraph == nil || len(block.Paragraph.Elements) != 1 {
t.Fatalf("expected 1 paragraph element, got %d", len(block.Paragraph.Elements))
}
elem := block.Paragraph.Elements[0]
if elem.ParagraphElementType == nil || *elem.ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected textRun element")
}
if elem.TextRun == nil || elem.TextRun.Text == nil || *elem.TextRun.Text != "Hello world" {
t.Fatalf("unexpected text: %v", elem.TextRun)
}
}
func TestSemiPlainContentToContentBlock_WithMentions(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Please review",
Mention: []string{"ou_123", "ou_456"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
if len(cb.Blocks) != 1 {
t.Fatalf("expected 1 block, got %d", len(cb.Blocks))
}
elems := cb.Blocks[0].Paragraph.Elements
if len(elems) != 3 {
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun || *elems[0].TextRun.Text != "Please review" {
t.Fatal("unexpected first element")
}
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_123" {
t.Fatal("unexpected second element")
}
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_456" {
t.Fatal("unexpected third element")
}
}
func TestSemiPlainContentToContentBlock_EmptyText(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: " ",
Mention: []string{"ou_123"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Empty text should be skipped, only mention remains
if len(elems) != 1 {
t.Fatalf("expected 1 element (mention only), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected mention element")
}
}
func TestSemiPlainContentToContentBlock_DocsImagesIgnored(t *testing.T) {
t.Parallel()
sp := &SemiPlainContent{
Text: "Test",
Mention: []string{"ou_123"},
Docs: []SemiPlainDoc{{Title: "Doc", URL: "https://..."}},
Images: []string{"https://img.png"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Docs and images are ignored in input conversion
if len(elems) != 2 {
t.Fatalf("expected 2 elements (text + mention), got %d", len(elems))
}
}
func TestSemiPlainContentToContentBlock_PlaceholderStripping(t *testing.T) {
t.Parallel()
// Simulate round-trip: output format has @{userID} in text,
// input conversion should strip them to avoid duplicate mentions
sp := &SemiPlainContent{
Text: "任务一 @{ou_zhangsan} ,任务二 @{ou_lisi} ",
Mention: []string{"ou_zhangsan", "ou_lisi"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Should have 3 elements: 1 text (stripped) + 2 mentions
if len(elems) != 3 {
t.Fatalf("expected 3 elements (1 text + 2 mentions), got %d", len(elems))
}
// Text should have placeholders stripped
if *elems[0].ParagraphElementType != ParagraphElementTypeTextRun {
t.Fatal("expected first element to be textRun")
}
// Note: space before comma is preserved from the placeholder's trailing space
expectedText := "任务一 ,任务二"
if *elems[0].TextRun.Text != expectedText {
t.Fatalf("expected stripped text '%s', got '%s'", expectedText, *elems[0].TextRun.Text)
}
// Mentions should be preserved as separate elements
if *elems[1].ParagraphElementType != ParagraphElementTypeMention || *elems[1].Mention.UserID != "ou_zhangsan" {
t.Fatal("unexpected second element")
}
if *elems[2].ParagraphElementType != ParagraphElementTypeMention || *elems[2].Mention.UserID != "ou_lisi" {
t.Fatal("unexpected third element")
}
}
func TestSemiPlainContentToContentBlock_OnlyPlaceholders(t *testing.T) {
t.Parallel()
// Text that is only placeholders should result in no text element
sp := &SemiPlainContent{
Text: " @{ou_123} @{ou_456} ",
Mention: []string{"ou_123", "ou_456"},
}
cb := sp.ToContentBlock()
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
// Should have only 2 mention elements, no text element
if len(elems) != 2 {
t.Fatalf("expected 2 elements (mentions only), got %d", len(elems))
}
if *elems[0].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected first element to be mention")
}
if *elems[1].ParagraphElementType != ParagraphElementTypeMention {
t.Fatal("expected second element to be mention")
}
}
func TestSemiPlainContentToContentBlock_Nil(t *testing.T) {
t.Parallel()
var sp *SemiPlainContent
cb := sp.ToContentBlock()
if cb != nil {
t.Fatal("expected nil ContentBlock for nil SemiPlainContent")
}
}
func TestBuildContentBlock_Conversion(t *testing.T) {
t.Parallel()
cb := BuildContentBlock("Test text", []string{"ou_123", "ou_456"})
if cb == nil {
t.Fatal("expected non-nil ContentBlock")
}
elems := cb.Blocks[0].Paragraph.Elements
if len(elems) != 3 {
t.Fatalf("expected 3 elements, got %d", len(elems))
}
if *elems[0].TextRun.Text != "Test text" {
t.Fatalf("unexpected text: %s", *elems[0].TextRun.Text)
}
if *elems[1].Mention.UserID != "ou_123" {
t.Fatalf("unexpected mention: %s", *elems[1].Mention.UserID)
}
if *elems[2].Mention.UserID != "ou_456" {
t.Fatalf("unexpected mention: %s", *elems[2].Mention.UserID)
}
}
func TestToSimpleMethods(t *testing.T) {
t.Parallel()
// Test Objective.ToSimple()
text := "Objective text"
obj := &Objective{
ID: "obj-1",
Content: BuildContentBlock(text, []string{"ou_123"}),
Notes: BuildContentBlock("Note text", nil),
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_owner")},
CycleID: "cycle-1",
Score: float64Ptr(0.7),
Weight: float64Ptr(0.5),
Deadline: strPtr("1735776000000"),
}
simpleObj := obj.ToSimple()
if simpleObj == nil {
t.Fatal("expected non-nil RespObjectiveSimple")
}
if simpleObj.ID != "obj-1" {
t.Fatalf("expected ID obj-1, got %s", simpleObj.ID)
}
// Text includes @{userID} placeholder for positional context
expectedContentText := "Objective text @{ou_123} "
if simpleObj.Content == nil || simpleObj.Content.Text != expectedContentText {
t.Fatalf("unexpected content text: expected '%s', got '%s'", expectedContentText, simpleObj.Content.Text)
}
if simpleObj.Notes == nil || simpleObj.Notes.Text != "Note text" {
t.Fatalf("unexpected notes: %+v", simpleObj.Notes)
}
if simpleObj.Score == nil || *simpleObj.Score != 0.7 {
t.Fatalf("unexpected score: %v", simpleObj.Score)
}
if len(simpleObj.Content.Mention) != 1 || simpleObj.Content.Mention[0] != "ou_123" {
t.Fatalf("unexpected mentions: %v", simpleObj.Content.Mention)
}
// Test KeyResult.ToSimple()
kr := &KeyResult{
ID: "kr-1",
ObjectiveID: "obj-1",
Content: BuildContentBlock("KR text", nil),
Owner: Owner{OwnerType: OwnerTypeUser, UserID: strPtr("ou_kr_owner")},
Score: float64Ptr(0.5),
}
simpleKR := kr.ToSimple()
if simpleKR == nil {
t.Fatal("expected non-nil RespKeyResultSimple")
}
if simpleKR.Content == nil || simpleKR.Content.Text != "KR text" {
t.Fatalf("unexpected KR content: %+v", simpleKR.Content)
}
// Test ProgressV1.ToSimple()
progress := &ProgressV1{
ID: "prog-1",
ModifyTime: "1735776000000",
Content: BuildContentBlock("Progress text", []string{"ou_mention"}).ToV1(),
}
simpleProgress := progress.ToSimple()
if simpleProgress == nil {
t.Fatal("expected non-nil RespProgressSimple")
}
// Text includes @{userID} placeholder for positional context
expectedProgressText := "Progress text @{ou_mention} "
if simpleProgress.Content == nil || simpleProgress.Content.Text != expectedProgressText {
t.Fatalf("unexpected progress text: expected '%s', got '%s'", expectedProgressText, simpleProgress.Content.Text)
}
if len(simpleProgress.Content.Mention) != 1 || simpleProgress.Content.Mention[0] != "ou_mention" {
t.Fatalf("unexpected progress mentions: %v", simpleProgress.Content.Mention)
}
// Test Progress.ToSimple() (V2 progress record)
progressV2 := &Progress{
ID: "prog-v2-1",
CreateTime: "1735689600000",
UpdateTime: "1735776000000",
Content: BuildContentBlock("V2 progress text", []string{"ou_v2_mention"}),
ProgressRate: &ProgressRate{
ProgressPercent: float64Ptr(80.0),
ProgressStatus: int32Ptr(int32(ProgressStatusDone)),
},
}
simpleProgressV2 := progressV2.ToSimple()
if simpleProgressV2 == nil {
t.Fatal("expected non-nil RespProgressSimple for Progress V2")
}
if simpleProgressV2.ID != "prog-v2-1" {
t.Fatalf("expected ID prog-v2-1, got %s", simpleProgressV2.ID)
}
if simpleProgressV2.CreateTime == nil || *simpleProgressV2.CreateTime == "" {
t.Fatal("expected non-empty CreateTime for Progress V2")
}
expectedV2Text := "V2 progress text @{ou_v2_mention} "
if simpleProgressV2.Content == nil || simpleProgressV2.Content.Text != expectedV2Text {
t.Fatalf("unexpected V2 progress text: expected '%s', got '%s'", expectedV2Text, simpleProgressV2.Content.Text)
}
if simpleProgressV2.ProgressRate == nil || simpleProgressV2.ProgressRate.Status == nil || *simpleProgressV2.ProgressRate.Status != "done" {
t.Fatalf("expected progress status 'done', got %+v", simpleProgressV2.ProgressRate)
}
if simpleProgressV2.ProgressRate.Percent == nil || *simpleProgressV2.ProgressRate.Percent != 80.0 {
t.Fatalf("expected progress percent 80.0, got %v", simpleProgressV2.ProgressRate.Percent)
}
if len(simpleProgressV2.Content.Mention) != 1 || simpleProgressV2.Content.Mention[0] != "ou_v2_mention" {
t.Fatalf("unexpected V2 progress mentions: %v", simpleProgressV2.Content.Mention)
}
}
// listTypePtr returns a pointer to the given ListType value. // listTypePtr returns a pointer to the given ListType value.
func listTypePtr(v ListType) *ListType { return &v } func listTypePtr(v ListType) *ListType { return &v }

311
shortcuts/okr/okr_patch.go Normal file
View File

@@ -0,0 +1,311 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package okr
import (
"context"
"encoding/json"
"fmt"
"io"
"math"
"strconv"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// patchParams holds the parsed parameters for the patch operation.
type patchParams struct {
Level string
TargetID string
Style string
Content *ContentBlock
Notes *ContentBlock
Score *float64
Deadline *string
UserIDType string
}
// parsePatchParams parses and validates flags from runtime into request-ready parameters.
func parsePatchParams(runtime *common.RuntimeContext) (*patchParams, error) {
p := &patchParams{
Level: runtime.Str("level"),
TargetID: runtime.Str("target-id"),
Style: runtime.Str("style"),
UserIDType: runtime.Str("user-id-type"),
}
hasField := false
// Parse content if provided
if contentStr := runtime.Str("content"); contentStr != "" {
hasField = true
if err := common.RejectDangerousCharsTyped("--content", contentStr); err != nil {
return nil, err
}
if p.Style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(contentStr), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
p.Content = sp.ToContentBlock()
} else {
var cb ContentBlock
if err := json.Unmarshal([]byte(contentStr), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
p.Content = &cb
}
}
// Parse notes if provided (only for objective)
if notesStr := runtime.Str("notes"); notesStr != "" {
hasField = true
if err := common.RejectDangerousCharsTyped("--notes", notesStr); err != nil {
return nil, err
}
if p.Level != "objective" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes is only supported for level=objective").WithParam("--notes")
}
if p.Style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(notesStr), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--notes").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes text is required and cannot be empty").WithParam("--notes")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes mention[%d] cannot be empty", i).WithParam("--notes")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--notes")
}
p.Notes = sp.ToContentBlock()
} else {
var cb ContentBlock
if err := json.Unmarshal([]byte(notesStr), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--notes must be valid ContentBlock JSON: %s", err).WithParam("--notes").WithCause(err)
}
p.Notes = &cb
}
}
// Parse score if provided
if scoreStr := runtime.Str("score"); scoreStr != "" {
hasField = true
score, err := strconv.ParseFloat(scoreStr, 64)
if err != nil || math.IsNaN(score) || math.IsInf(score, 0) {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be a valid number").WithParam("--score")
}
if score < 0 || score > 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must be between 0 and 1").WithParam("--score")
}
// Check for exactly one decimal place
scoreStrTrimmed := strings.TrimRight(strings.TrimRight(scoreStr, "0"), ".")
parts := strings.Split(scoreStrTrimmed, ".")
if len(parts) == 2 && len(parts[1]) > 1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--score must have at most one decimal place (e.g., 0.5, not 0.51)").WithParam("--score")
}
// Validation ensures at most one decimal place, so score is already correctly formatted
p.Score = &score
}
// Parse deadline if provided
if deadlineStr := runtime.Str("deadline"); deadlineStr != "" {
hasField = true
deadlineMs, err := strconv.ParseInt(deadlineStr, 10, 64)
if err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a valid millisecond timestamp (integer)").WithParam("--deadline")
}
if deadlineMs <= 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a positive millisecond timestamp").WithParam("--deadline")
}
// Reject non-millisecond timestamps: year 2000 in ms is ~946e9, year 2100 in ms is ~4.1e12
// Anything less than 1e12 is likely seconds or a wrong unit
if deadlineMs < 1000000000000 { // 1e12 ms = year ~33658, so use 1e12 as lower bound for reasonable ms timestamps
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--deadline must be a millisecond timestamp (13 digits), not seconds").WithParam("--deadline")
}
p.Deadline = &deadlineStr
}
// At least one field must be provided
if !hasField {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "at least one of --content, --notes, --score, or --deadline must be provided")
}
return p, nil
}
// OKRPatch patches an objective or key result.
var OKRPatch = common.Shortcut{
Service: "okr",
Command: "+patch",
Description: "Patch an OKR objective or key result (content, notes, score, deadline)",
Risk: "write",
Scopes: []string{"okr:okr.content:writeonly"},
AuthTypes: []string{"user", "bot"},
HasFormat: true,
Flags: []common.Flag{
{Name: "level", Desc: "patch level: objective | key-result", Required: true, Enum: []string{"objective", "key-result"}},
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
{Name: "style", Default: "simple", Desc: "input style for content/notes: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
{Name: "content", Desc: "content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
{Name: "notes", Desc: "notes (objective only): semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple) or ContentBlock JSON (richtext)", Input: []string{common.File, common.Stdin}},
{Name: "score", Desc: "score value between 0 and 1, with at most one decimal place (e.g., 0.5)"},
{Name: "deadline", Desc: "deadline as millisecond timestamp"},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
level := runtime.Str("level")
if level != "objective" && level != "key-result" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--level must be one of: objective | key-result").WithParam("--level")
}
targetID := runtime.Str("target-id")
if targetID == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id is required").WithParam("--target-id")
}
if err := common.RejectDangerousCharsTyped("--target-id", targetID); err != nil {
return err
}
if id, err := strconv.ParseInt(targetID, 10, 64); err != nil || id <= 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--target-id must be a positive int64").WithParam("--target-id")
}
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
idType := runtime.Str("user-id-type")
if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
}
// Delegate content/notes/score/deadline validation to parsePatchParams
if _, err := parsePatchParams(runtime); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
p, err := parsePatchParams(runtime)
if err != nil {
return common.NewDryRunAPI().
PATCH("").
Desc(fmt.Sprintf("Dry-run skipped: %s", err.Error()))
}
body := make(map[string]interface{})
if p.Content != nil {
body["content"] = p.Content
}
if p.Notes != nil {
body["notes"] = p.Notes
}
if p.Score != nil {
body["score"] = *p.Score
}
if p.Deadline != nil {
body["deadline"] = *p.Deadline
}
params := map[string]interface{}{
"user_id_type": p.UserIDType,
}
api := common.NewDryRunAPI()
if p.Level == "objective" {
api = api.PATCH("/open-apis/okr/v2/objectives/:objective_id").
Set("objective_id", p.TargetID)
} else {
api = api.PATCH("/open-apis/okr/v2/key_results/:key_result_id").
Set("key_result_id", p.TargetID)
}
return api.Params(params).Body(body).
Desc(fmt.Sprintf("Patch OKR %s: content=%v, notes=%v, score=%v, deadline=%v",
p.Level, p.Content != nil, p.Notes != nil, p.Score != nil, p.Deadline != nil))
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
p, err := parsePatchParams(runtime)
if err != nil {
return err
}
body := make(map[string]interface{})
if p.Content != nil {
body["content"] = p.Content
}
if p.Notes != nil {
body["notes"] = p.Notes
}
if p.Score != nil {
body["score"] = *p.Score
}
if p.Deadline != nil {
body["deadline"] = *p.Deadline
}
queryParams := map[string]interface{}{
"user_id_type": p.UserIDType,
}
var path string
if p.Level == "objective" {
path = fmt.Sprintf("/open-apis/okr/v2/objectives/%s", p.TargetID)
} else {
path = fmt.Sprintf("/open-apis/okr/v2/key_results/%s", p.TargetID)
}
_, err = runtime.CallAPITyped("PATCH", path, queryParams, body)
if err != nil {
return wrapOkrNetworkErr(err, "failed to patch OKR %s", p.Level)
}
result := map[string]interface{}{
"level": p.Level,
"target_id": p.TargetID,
"patched": map[string]bool{
"content": p.Content != nil,
"notes": p.Notes != nil,
"score": p.Score != nil,
"deadline": p.Deadline != nil,
},
}
runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Patched OKR %s [%s]\n", p.Level, p.TargetID)
if p.Content != nil {
fmt.Fprintf(w, " - content: updated\n")
}
if p.Notes != nil {
fmt.Fprintf(w, " - notes: updated\n")
}
if p.Score != nil {
fmt.Fprintf(w, " - score: %.1f\n", *p.Score)
}
if p.Deadline != nil {
fmt.Fprintf(w, " - deadline: %s\n", formatTimestamp(*p.Deadline))
}
})
return nil
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,7 @@ import (
"io" "io"
"math" "math"
"strconv" "strconv"
"strings"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
@@ -35,12 +36,37 @@ type createProgressRecordParams struct {
// parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters. // parseCreateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) { func parseCreateProgressRecordParams(runtime *common.RuntimeContext) (*createProgressRecordParams, error) {
style := runtime.Str("style")
content := runtime.Str("content") content := runtime.Str("content")
var cb ContentBlock var contentV1 *ContentBlockV1
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
// Validate mention IDs are non-empty
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
// Build ContentBlock from semi-plain content (text + mentions)
contentV1 = sp.ToContentBlock().ToV1()
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 = cb.ToV1()
} }
contentV1 := cb.ToV1()
targetType := runtime.Str("target-type") targetType := runtime.Str("target-type")
targetTypeVal := targetTypeAllowed[targetType] targetTypeVal := targetTypeAllowed[targetType]
@@ -92,7 +118,7 @@ var OKRCreateProgressRecord = common.Shortcut{
AuthTypes: []string{"user", "bot"}, AuthTypes: []string{"user", "bot"},
HasFormat: true, HasFormat: true,
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}}, {Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true}, {Name: "target-id", Desc: "target ID (objective or key result ID)", Required: true},
{Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}}, {Name: "target-type", Desc: "target type: objective | key_result", Required: true, Enum: []string{"objective", "key_result"}},
{Name: "progress-percent", Desc: "progress percentage"}, {Name: "progress-percent", Desc: "progress percentage"},
@@ -100,6 +126,7 @@ var OKRCreateProgressRecord = common.Shortcut{
{Name: "source-title", Default: "created by lark-cli", Desc: "source title for display"}, {Name: "source-title", Default: "created by lark-cli", Desc: "source title for display"},
{Name: "source-url", Desc: "source URL for display (defaults to open platform URL based on brand)"}, {Name: "source-url", Desc: "source URL for display (defaults to open platform URL based on brand)"},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
content := runtime.Str("content") content := runtime.Str("content")
@@ -109,10 +136,36 @@ var OKRCreateProgressRecord = common.Shortcut{
if err := common.RejectDangerousCharsTyped("--content", content); err != nil { if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
return err return err
} }
// Validate content is valid JSON and can be parsed as ContentBlock
var cb ContentBlock style := runtime.Str("style")
if err := json.Unmarshal([]byte(content), &cb); err != nil { if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
// Validate content based on style
if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
// If user provided docs or images in simple mode, warn that they are ignored
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
} }
targetID := runtime.Str("target-id") targetID := runtime.Str("target-id")
@@ -213,21 +266,43 @@ var OKRCreateProgressRecord = common.Shortcut{
return err return err
} }
resp := record.ToResp() style := runtime.Str("style")
result := map[string]interface{}{ var result map[string]interface{}
"progress": resp, if style == "simple" {
} resp := record.ToSimple()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) { runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Created Progress [%s]\n", resp.ID) fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
}
})
} else {
resp := record.ToResp()
result = map[string]interface{}{
"progress": resp,
"style": style,
} }
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content) runtime.OutFormat(result, nil, func(w io.Writer) {
} fmt.Fprintf(w, "Created Progress [%s] (style: %s)\n", resp.ID, style)
}) fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
}
return nil return nil
}, },
} }

View File

@@ -5,11 +5,13 @@ package okr
import ( import (
"bytes" "bytes"
"errors"
"strings" "strings"
"testing" "testing"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/httpmock"
@@ -38,6 +40,7 @@ func runProgressCreateShortcut(t *testing.T, f *cmdutil.Factory, stdout *bytes.B
} }
const validContentBlockJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}` const validContentBlockJSON = `{"blocks":[{"block_element_type":"paragraph","paragraph":{"elements":[{"paragraph_element_type":"textRun","text_run":{"text":"test content"}}]}}]}`
const validSemiPlainJSON = `{"text":"test content","mention":["ou_123"]}`
// --- Validate tests --- // --- Validate tests ---
@@ -60,6 +63,7 @@ func TestProgressCreateValidate_InvalidContentJSON(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{ err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create", "+progress-create",
"--content", "not-json", "--content", "not-json",
"--style", "richtext",
"--target-id", "123", "--target-id", "123",
"--target-type", "objective", "--target-type", "objective",
}) })
@@ -77,6 +81,7 @@ func TestProgressCreateValidate_MissingTargetID(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{ err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create", "+progress-create",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--target-type", "objective", "--target-type", "objective",
}) })
if err == nil { if err == nil {
@@ -90,6 +95,7 @@ func TestProgressCreateValidate_InvalidTargetID_NonNumeric(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{ err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create", "+progress-create",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "abc", "--target-id", "abc",
"--target-type", "objective", "--target-type", "objective",
}) })
@@ -107,6 +113,7 @@ func TestProgressCreateValidate_InvalidTargetType(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{ err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create", "+progress-create",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123", "--target-id", "123",
"--target-type", "invalid", "--target-type", "invalid",
}) })
@@ -124,6 +131,7 @@ func TestProgressCreateValidate_ControlCharsInContent(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{ err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create", "+progress-create",
"--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}", "--content", "{\"blocks\":[{\"block_element_type\":\"para\tgraph\"}]}",
"--style", "richtext",
"--target-id", "123", "--target-id", "123",
"--target-type", "objective", "--target-type", "objective",
}) })
@@ -138,6 +146,7 @@ func TestProgressCreateValidate_InvalidUserIDType(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{ err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create", "+progress-create",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123", "--target-id", "123",
"--target-type", "objective", "--target-type", "objective",
"--user-id-type", "invalid", "--user-id-type", "invalid",
@@ -153,6 +162,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
err := runProgressCreateShortcut(t, f, stdout, []string{ err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create", "+progress-create",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123", "--target-id", "123",
"--target-type", "objective", "--target-type", "objective",
"--progress-percent", "999999999999", "--progress-percent", "999999999999",
@@ -171,6 +181,7 @@ func TestProgressCreateValidate_InvalidProgressPercent_NonNumeric(t *testing.T)
err := runProgressCreateShortcut(t, f, stdout, []string{ err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create", "+progress-create",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123", "--target-id", "123",
"--target-type", "objective", "--target-type", "objective",
"--progress-percent", "abc", "--progress-percent", "abc",
@@ -189,6 +200,7 @@ func TestProgressCreateValidate_InvalidProgressStatus(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{ err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create", "+progress-create",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123", "--target-id", "123",
"--target-type", "objective", "--target-type", "objective",
"--progress-status", "invalid_status", "--progress-status", "invalid_status",
@@ -219,6 +231,7 @@ func TestProgressCreateValidate_Valid(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{ err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create", "+progress-create",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123", "--target-id", "123",
"--target-type", "objective", "--target-type", "objective",
}) })
@@ -235,6 +248,7 @@ func TestProgressCreateDryRun(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{ err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create", "+progress-create",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123", "--target-id", "123",
"--target-type", "objective", "--target-type", "objective",
"--dry-run", "--dry-run",
@@ -264,6 +278,7 @@ func TestProgressCreateDryRun_WithProgressRate(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{ err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create", "+progress-create",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "123", "--target-id", "123",
"--target-type", "objective", "--target-type", "objective",
"--progress-percent", "75", "--progress-percent", "75",
@@ -299,6 +314,7 @@ func TestProgressCreateExecute_Success(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{ err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create", "+progress-create",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "456", "--target-id", "456",
"--target-type", "key_result", "--target-type", "key_result",
}) })
@@ -330,6 +346,7 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
err := runProgressCreateShortcut(t, f, stdout, []string{ err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create", "+progress-create",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--target-id", "789", "--target-id", "789",
"--target-type", "objective", "--target-type", "objective",
}) })
@@ -337,3 +354,200 @@ func TestProgressCreateExecute_APIError(t *testing.T) {
t.Fatal("expected error for API failure") t.Fatal("expected error for API failure")
} }
} }
// --- Simple mode tests ---
func TestProgressCreateExecute_SimpleMode_DefaultStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v1/progress_records/",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "300",
"modify_time": "1735776000000",
},
},
})
// Use default style (simple) without specifying --style
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validSemiPlainJSON,
"--target-id", "123",
"--target-type", "objective",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "300" {
t.Fatalf("progress_id = %v, want 300", pr["progress_id"])
}
}
func TestProgressCreateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressCreateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "POST",
URL: "/open-apis/okr/v1/progress_records/",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "400",
"modify_time": "1735776000000",
},
},
})
// Explicitly specify --style simple with mentions
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":"simple progress with mention","mention":["ou_abc","ou_def"]}`,
"--style", "simple",
"--target-id", "456",
"--target-type", "key_result",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "400" {
t.Fatalf("progress_id = %v, want 400", pr["progress_id"])
}
}
func TestProgressCreateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":"missing closing brace`,
"--target-id", "123",
"--target-type", "objective",
})
if err == nil {
t.Fatal("expected error for invalid semi-plain JSON")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressCreateValidate_SimpleMode_EmptyText(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":" ","mention":[]}`,
"--target-id", "123",
"--target-type", "objective",
})
if err == nil {
t.Fatal("expected error for empty text in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content text is required and cannot be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressCreateValidate_SimpleMode_DocsImagesNotSupported(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", `{"text":"has docs","mention":[],"docs":[{"title":"doc","url":"https://example.com"}]}`,
"--target-id", "123",
"--target-type", "objective",
})
if err == nil {
t.Fatal("expected error for docs in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressCreateDryRun_SimpleMode(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressCreateTestConfig(t))
err := runProgressCreateShortcut(t, f, stdout, []string{
"+progress-create",
"--content", validSemiPlainJSON,
"--target-id", "123",
"--target-type", "objective",
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/") {
t.Fatalf("dry-run output should contain API path, got: %s", output)
}
if !strings.Contains(output, "POST") {
t.Fatalf("dry-run output should contain POST method, got: %s", output)
}
}

View File

@@ -26,6 +26,7 @@ var OKRGetProgressRecord = common.Shortcut{
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "progress-id", Desc: "progress ID (int64)", Required: true}, {Name: "progress-id", Desc: "progress ID (int64)", Required: true},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "style", Default: "simple", Desc: "output style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id") progressID := runtime.Str("progress-id")
@@ -39,6 +40,10 @@ var OKRGetProgressRecord = common.Shortcut{
if idType != "open_id" && idType != "union_id" && idType != "user_id" { if idType != "open_id" && idType != "union_id" && idType != "user_id" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type") return errs.NewValidationError(errs.SubtypeInvalidArgument, "--user-id-type must be one of: open_id | union_id | user_id").WithParam("--user-id-type")
} }
style := runtime.Str("style")
if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
return nil return nil
}, },
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -55,6 +60,7 @@ var OKRGetProgressRecord = common.Shortcut{
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error { Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id") progressID := runtime.Str("progress-id")
userIDType := runtime.Str("user-id-type") userIDType := runtime.Str("user-id-type")
style := runtime.Str("style")
queryParams := map[string]interface{}{"user_id_type": userIDType} queryParams := map[string]interface{}{"user_id_type": userIDType}
@@ -69,21 +75,45 @@ var OKRGetProgressRecord = common.Shortcut{
return err return err
} }
resp := record.ToResp() var result map[string]interface{}
result := map[string]interface{}{ if style == "simple" {
"progress": resp, resp := record.ToSimple()
} result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) { runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Progress [%s]\n", resp.ID) fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent) fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
if len(resp.Content.Mention) > 0 {
fmt.Fprintf(w, " Mentions: %v\n", resp.Content.Mention)
}
}
})
} else {
resp := record.ToResp()
result = map[string]interface{}{
"progress": resp,
"style": style,
} }
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content) runtime.OutFormat(result, nil, func(w io.Writer) {
} fmt.Fprintf(w, "Progress [%s] (style: %s)\n", resp.ID, style)
}) fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " ProgressRate: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
}
return nil return nil
}, },
} }

View File

@@ -10,6 +10,7 @@ import (
"io" "io"
"math" "math"
"strconv" "strconv"
"strings"
"github.com/larksuite/cli/errs" "github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common" "github.com/larksuite/cli/shortcuts/common"
@@ -25,12 +26,35 @@ type updateProgressRecordParams struct {
// parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters. // parseUpdateProgressRecordParams parses and validates flags from runtime into request-ready parameters.
func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) { func parseUpdateProgressRecordParams(runtime *common.RuntimeContext) (*updateProgressRecordParams, error) {
style := runtime.Str("style")
content := runtime.Str("content") content := runtime.Str("content")
var cb ContentBlock var contentV1 *ContentBlockV1
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
contentV1 = sp.ToContentBlock().ToV1()
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
contentV1 = cb.ToV1()
} }
contentV1 := cb.ToV1()
var progressRate *ProgressRateV1 var progressRate *ProgressRateV1
if v := runtime.Str("progress-percent"); v != "" { if v := runtime.Str("progress-percent"); v != "" {
@@ -67,10 +91,11 @@ var OKRUpdateProgressRecord = common.Shortcut{
HasFormat: true, HasFormat: true,
Flags: []common.Flag{ Flags: []common.Flag{
{Name: "progress-id", Desc: "progress ID (int64)", Required: true}, {Name: "progress-id", Desc: "progress ID (int64)", Required: true},
{Name: "content", Desc: "progress content in ContentBlock JSON format", Required: true, Input: []string{common.File, common.Stdin}}, {Name: "content", Desc: "progress content: semi-plain JSON {\"text\":\"...\",\"mention\":[\"...\"]} (simple style) or ContentBlock JSON (richtext style)", Required: true, Input: []string{common.File, common.Stdin}},
{Name: "progress-percent", Desc: "progress percentage"}, {Name: "progress-percent", Desc: "progress percentage"},
{Name: "progress-status", Desc: "progress status: normal | overdue | done", Enum: []string{"normal", "overdue", "done"}}, {Name: "progress-status", Desc: "progress status: normal | overdue | done", Enum: []string{"normal", "overdue", "done"}},
{Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"}, {Name: "user-id-type", Default: "open_id", Desc: "user ID type: open_id | union_id | user_id"},
{Name: "style", Default: "simple", Desc: "input style: simple (semi-plain text JSON) | richtext (ContentBlock JSON)", Enum: []string{"simple", "richtext"}},
}, },
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error { Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
progressID := runtime.Str("progress-id") progressID := runtime.Str("progress-id")
@@ -88,9 +113,35 @@ var OKRUpdateProgressRecord = common.Shortcut{
if err := common.RejectDangerousCharsTyped("--content", content); err != nil { if err := common.RejectDangerousCharsTyped("--content", content); err != nil {
return err return err
} }
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil { style := runtime.Str("style")
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err) if style != "simple" && style != "richtext" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--style must be one of: simple | richtext").WithParam("--style")
}
// Validate content based on style
if style == "simple" {
var sp SemiPlainContent
if err := json.Unmarshal([]byte(content), &sp); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid semi-plain JSON: {\"text\":\"...\",\"mention\":[\"...\"]}: %s", err).WithParam("--content").WithCause(err)
}
if strings.TrimSpace(sp.Text) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content text is required and cannot be empty").WithParam("--content")
}
for i, m := range sp.Mention {
if strings.TrimSpace(m) == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content mention[%d] cannot be empty", i).WithParam("--content")
}
}
if len(sp.Docs) > 0 || len(sp.Images) > 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content docs and images are not supported in simple style input; use richtext style or remove these fields").WithParam("--content")
}
} else {
// richtext mode
var cb ContentBlock
if err := json.Unmarshal([]byte(content), &cb); err != nil {
return errs.NewValidationError(errs.SubtypeInvalidArgument, "--content must be valid ContentBlock JSON: %s", err).WithParam("--content").WithCause(err)
}
} }
if v := runtime.Str("progress-percent"); v != "" { if v := runtime.Str("progress-percent"); v != "" {
@@ -158,21 +209,43 @@ var OKRUpdateProgressRecord = common.Shortcut{
return err return err
} }
resp := record.ToResp() style := runtime.Str("style")
result := map[string]interface{}{ var result map[string]interface{}
"progress": resp, if style == "simple" {
} resp := record.ToSimple()
result = map[string]interface{}{
"progress": resp,
"style": style,
}
runtime.OutFormat(result, nil, func(w io.Writer) { runtime.OutFormat(result, nil, func(w io.Writer) {
fmt.Fprintf(w, "Updated Progress [%s]\n", resp.ID) fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime) fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil { if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent) fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", resp.Content.Text)
}
})
} else {
resp := record.ToResp()
result = map[string]interface{}{
"progress": resp,
"style": style,
} }
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content) runtime.OutFormat(result, nil, func(w io.Writer) {
} fmt.Fprintf(w, "Updated Progress [%s] (style: %s)\n", resp.ID, style)
}) fmt.Fprintf(w, " ModifyTime: %s\n", resp.ModifyTime)
if resp.ProgressRate != nil && resp.ProgressRate.Percent != nil {
fmt.Fprintf(w, " Progress: %.1f%%\n", *resp.ProgressRate.Percent)
}
if resp.Content != nil {
fmt.Fprintf(w, " Content: %s\n", *resp.Content)
}
})
}
return nil return nil
}, },
} }

View File

@@ -5,11 +5,13 @@ package okr
import ( import (
"bytes" "bytes"
"errors"
"strings" "strings"
"testing" "testing"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil" "github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock" "github.com/larksuite/cli/internal/httpmock"
@@ -45,6 +47,7 @@ func TestProgressUpdateValidate_MissingProgressID(t *testing.T) {
err := runProgressUpdateShortcut(t, f, stdout, []string{ err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update", "+progress-update",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
}) })
if err == nil { if err == nil {
t.Fatal("expected error for missing --progress-id") t.Fatal("expected error for missing --progress-id")
@@ -58,6 +61,7 @@ func TestProgressUpdateValidate_InvalidProgressID(t *testing.T) {
"+progress-update", "+progress-update",
"--progress-id", "abc", "--progress-id", "abc",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
}) })
if err == nil { if err == nil {
t.Fatal("expected error for invalid --progress-id") t.Fatal("expected error for invalid --progress-id")
@@ -86,6 +90,7 @@ func TestProgressUpdateValidate_InvalidContentJSON(t *testing.T) {
"+progress-update", "+progress-update",
"--progress-id", "123", "--progress-id", "123",
"--content", "not-json", "--content", "not-json",
"--style", "richtext",
}) })
if err == nil { if err == nil {
t.Fatal("expected error for invalid --content JSON") t.Fatal("expected error for invalid --content JSON")
@@ -102,6 +107,7 @@ func TestProgressUpdateValidate_InvalidUserIDType(t *testing.T) {
"+progress-update", "+progress-update",
"--progress-id", "123", "--progress-id", "123",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--user-id-type", "invalid", "--user-id-type", "invalid",
}) })
if err == nil { if err == nil {
@@ -116,6 +122,7 @@ func TestProgressUpdateValidate_InvalidProgressPercent_OutOfRange(t *testing.T)
"+progress-update", "+progress-update",
"--progress-id", "123", "--progress-id", "123",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--progress-percent", "-999999999999", "--progress-percent", "-999999999999",
}) })
if err == nil { if err == nil {
@@ -133,6 +140,7 @@ func TestProgressUpdateValidate_InvalidProgressStatus(t *testing.T) {
"+progress-update", "+progress-update",
"--progress-id", "123", "--progress-id", "123",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--progress-status", "invalid_status", "--progress-status", "invalid_status",
}) })
if err == nil { if err == nil {
@@ -162,6 +170,7 @@ func TestProgressUpdateValidate_Valid(t *testing.T) {
"+progress-update", "+progress-update",
"--progress-id", "123", "--progress-id", "123",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
}) })
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -177,6 +186,7 @@ func TestProgressUpdateDryRun(t *testing.T) {
"+progress-update", "+progress-update",
"--progress-id", "456", "--progress-id", "456",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--dry-run", "--dry-run",
}) })
if err != nil { if err != nil {
@@ -201,6 +211,7 @@ func TestProgressUpdateDryRun_WithProgressRate(t *testing.T) {
"+progress-update", "+progress-update",
"--progress-id", "456", "--progress-id", "456",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
"--progress-percent", "50", "--progress-percent", "50",
"--progress-status", "overdue", "--progress-status", "overdue",
"--dry-run", "--dry-run",
@@ -235,6 +246,7 @@ func TestProgressUpdateExecute_Success(t *testing.T) {
"+progress-update", "+progress-update",
"--progress-id", "789", "--progress-id", "789",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
}) })
if err != nil { if err != nil {
t.Fatalf("unexpected error: %v", err) t.Fatalf("unexpected error: %v", err)
@@ -265,8 +277,202 @@ func TestProgressUpdateExecute_APIError(t *testing.T) {
"+progress-update", "+progress-update",
"--progress-id", "999", "--progress-id", "999",
"--content", validContentBlockJSON, "--content", validContentBlockJSON,
"--style", "richtext",
}) })
if err == nil { if err == nil {
t.Fatal("expected error for API failure") t.Fatal("expected error for API failure")
} }
} }
// --- Simple mode tests ---
func TestProgressUpdateExecute_SimpleMode_DefaultStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v1/progress_records/500",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "500",
"modify_time": "1735776000000",
},
},
})
// Use default style (simple) without specifying --style
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "500",
"--content", validSemiPlainJSON,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "500" {
t.Fatalf("progress_id = %v, want 500", pr["progress_id"])
}
}
func TestProgressUpdateExecute_SimpleMode_ExplicitStyle(t *testing.T) {
t.Parallel()
f, stdout, _, reg := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
reg.Register(&httpmock.Stub{
Method: "PUT",
URL: "/open-apis/okr/v1/progress_records/600",
Body: map[string]interface{}{
"code": 0,
"msg": "ok",
"data": map[string]interface{}{
"progress_id": "600",
"modify_time": "1735776000000",
},
},
})
// Explicitly specify --style simple with mentions and progress rate
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "600",
"--content", `{"text":"updated progress","mention":["ou_abc"]}`,
"--style", "simple",
"--progress-percent", "80",
"--progress-status", "normal",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
data := decodeEnvelope(t, stdout)
pr, _ := data["progress"].(map[string]interface{})
if pr == nil {
t.Fatal("expected progress in output")
}
if pr["progress_id"] != "600" {
t.Fatalf("progress_id = %v, want 600", pr["progress_id"])
}
}
func TestProgressUpdateValidate_SimpleMode_InvalidSemiPlainJSON(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "123",
"--content", `{"text":"invalid json`,
})
if err == nil {
t.Fatal("expected error for invalid semi-plain JSON")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content must be valid semi-plain JSON") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressUpdateValidate_SimpleMode_EmptyMention(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "123",
"--content", `{"text":"has empty mention","mention":["ou_abc",""]}`,
})
if err == nil {
t.Fatal("expected error for empty mention in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "--content mention[1] cannot be empty") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressUpdateValidate_SimpleMode_ImagesNotSupported(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "123",
"--content", `{"text":"has images","mention":[],"images":["img_token"]}`,
})
if err == nil {
t.Fatal("expected error for images in simple mode")
}
problem, ok := errs.ProblemOf(err)
if !ok {
t.Fatalf("expected typed error, got: %v", err)
}
if problem.Category != errs.CategoryValidation {
t.Fatalf("expected category %q, got %q", errs.CategoryValidation, problem.Category)
}
if problem.Subtype != errs.SubtypeInvalidArgument {
t.Fatalf("expected subtype %q, got %q", errs.SubtypeInvalidArgument, problem.Subtype)
}
var validationErr *errs.ValidationError
if !errors.As(err, &validationErr) {
t.Fatalf("expected *errs.ValidationError, got: %T", err)
}
if validationErr.Param != "--content" {
t.Fatalf("expected param %q, got %q", "--content", validationErr.Param)
}
if !strings.Contains(err.Error(), "docs and images are not supported in simple style input") {
t.Fatalf("unexpected error: %v", err)
}
}
func TestProgressUpdateDryRun_SimpleMode(t *testing.T) {
t.Parallel()
f, stdout, _, _ := cmdutil.TestFactory(t, progressUpdateTestConfig(t))
err := runProgressUpdateShortcut(t, f, stdout, []string{
"+progress-update",
"--progress-id", "700",
"--content", validSemiPlainJSON,
"--dry-run",
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
output := stdout.String()
if !strings.Contains(output, "/open-apis/okr/v1/progress_records/700") {
t.Fatalf("dry-run output should contain API path, got: %s", output)
}
if !strings.Contains(output, "PUT") {
t.Fatalf("dry-run output should contain PUT method, got: %s", output)
}
}

View File

@@ -22,5 +22,6 @@ func Shortcuts() []common.Shortcut {
OKRReorder, OKRReorder,
OKRWeight, OKRWeight,
OKRIndicatorUpdate, OKRIndicatorUpdate,
OKRPatch,
} }
} }

View File

@@ -170,6 +170,27 @@ func TestRegisterShortcutsMountsDocsMediaPreview(t *testing.T) {
} }
} }
func TestRegisterShortcutsMountsDocsHistoryCommands(t *testing.T) {
program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t))
for _, name := range []string{"+history-list", "+history-revert", "+history-revert-status"} {
cmd, _, err := program.Find([]string{"docs", name})
if err != nil {
t.Fatalf("find docs %s shortcut: %v", name, err)
}
if cmd == nil || cmd.Name() != name {
t.Fatalf("docs %s shortcut not mounted: %#v", name, cmd)
}
if cmd.Flags().Lookup("api-version") != nil {
t.Fatalf("docs %s should not expose --api-version", name)
}
if !strings.Contains(cmd.Long, "lark-cli skills read lark-doc references/lark-doc-history.md") {
t.Fatalf("docs %s help missing history skill guidance:\n%s", name, cmd.Long)
}
}
}
func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) { func TestRegisterShortcutsDocsHelpAddsSkillReadGuidance(t *testing.T) {
program := &cobra.Command{Use: "root"} program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t)) RegisterShortcuts(program, newRegisterTestFactory(t))

View File

@@ -159,7 +159,7 @@ func TestHandleTaskApiResultWithContext_PermissionConsoleURL(t *testing.T) {
if pe.Subtype != errs.SubtypeAppScopeNotApplied { if pe.Subtype != errs.SubtypeAppScopeNotApplied {
t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied) t.Errorf("subtype = %q, want %q", pe.Subtype, errs.SubtypeAppScopeNotApplied)
} }
if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/app/cli_a123/auth") { if pe.ConsoleURL == "" || !strings.Contains(pe.ConsoleURL, "open.larksuite.com/page/scope-apply?clientID=cli_a123") {
t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL) t.Errorf("ConsoleURL = %q, want Lark developer console URL", pe.ConsoleURL)
} }
if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" { if len(pe.MissingScopes) != 1 || pe.MissingScopes[0] != "task:attachment:write" {

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