Compare commits

..

37 Commits

Author SHA1 Message Date
leave330
beee72f844 docs: defer standalone lark-application skill to a later iteration 2026-07-08 19:37:07 +08:00
leave330
78acceb910 fix: preserve cause chain when rewrapping name-collision error 2026-07-08 17:53:18 +08:00
leave330
cb8d09d802 test: use conventional placeholder credentials in app test config 2026-07-08 17:27:22 +08:00
leave330
42dc50f5fb test: build delete stub URL from shared base path constant 2026-07-08 17:16:02 +08:00
leave330
59ccffb09a docs: refine lark-application skill guide 2026-07-08 17:10:01 +08:00
leave330
d4f62490d0 fix: limit recommended tier to slash-command read scope 2026-07-08 16:10:18 +08:00
leave330
d8327726b0 feat: expose application domain in interactive auth login 2026-07-08 16:00:16 +08:00
leave330
3a297df5b0 docs: note interactive login picker does not yet list application domain 2026-07-08 15:38:05 +08:00
leave330
dce04f792a fix: declare shared scopes on application shortcuts for user preflight 2026-07-08 15:09:52 +08:00
leave330
fd00d43406 fix: preserve high-risk note in delete dry-run and document force i18n caveat 2026-07-08 13:28:36 +08:00
leave330
f2f49a68f4 test: add application slash command dry-run E2E coverage
Pin the dry-run request shapes for +slash-command-list/create/update/delete
(GET/POST/PATCH/DELETE paths, top-level icon key sibling to description,
description.i18n) and confirm the high-risk-write delete path exits 10 with
a confirmation_required envelope on stderr when --yes is omitted.
2026-07-08 12:38:59 +08:00
leave330
42c4b7d155 fix: classify slash command not-found as API error and preserve conflict code
- commandNotFoundError now returns errs.NewAPIError(SubtypeNotFound, ...)
  instead of a validation error: a resolution miss against the live list
  means the resource doesn't exist, not that the caller's argument shape
  was invalid.
- The no-force name-collision rewrap in +slash-command-create now carries
  over the original Code/LogID from the upstream conflict response instead
  of dropping them.
- +slash-command-delete now prints an extra stderr note that recreating the
  same command name yields a NEW command_id.
- Add a test guarding that --force only converts a name-collision response
  into an update; any other POST failure (e.g. invalid icon_key) must
  surface unchanged with no PATCH attempted.
2026-07-08 12:38:53 +08:00
leave330
96c8e638a1 docs: fix slash command trigger event reference in lark-application skill 2026-07-08 12:20:01 +08:00
leave330
ba8368504d docs: add lark-application skill for slash command management 2026-07-08 11:56:15 +08:00
leave330
c3adfc1789 feat: add slash-command-delete shortcut with high-risk confirmation 2026-07-08 11:49:00 +08:00
leave330
a290aa628f feat: add slash-command-update shortcut with by-name addressing 2026-07-08 11:43:36 +08:00
leave330
edeccaa419 feat: add slash-command-create shortcut with --force idempotent re-run 2026-07-08 11:38:39 +08:00
leave330
84962a1927 feat: add slash command name-to-id resolution helper 2026-07-08 11:29:38 +08:00
leave330
a72a562c7d feat: add application domain with slash-command-list shortcut 2026-07-08 11:25:20 +08:00
leave330
deb866f981 feat: add application domain description and slash command helpers 2026-07-08 11:17:15 +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
192 changed files with 7549 additions and 7635 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,49 @@
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 ## [v1.0.64] - 2026-07-02
### Features ### Features
@@ -1355,6 +1398,8 @@ 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.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

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

@@ -128,5 +128,5 @@ func getLoginMsg(lang i18n.Lang) *loginMsg {
// (not backed by from_meta service specs). Descriptions are now centralized in // (not backed by from_meta service specs). Descriptions are now centralized in
// service_descriptions.json. // service_descriptions.json.
func getShortcutOnlyDomainNames() []string { func getShortcutOnlyDomainNames() []string {
return []string{"base", "contact", "docs", "markdown", "apps", "note"} return []string{"application", "base", "contact", "docs", "markdown", "apps", "note"}
} }

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

@@ -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,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

@@ -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

@@ -248,10 +248,18 @@ func TestLoadPlatformAutoApproveSet(t *testing.T) {
func TestLoadOverrideAutoApproveAllow(t *testing.T) { func TestLoadOverrideAutoApproveAllow(t *testing.T) {
allowSet := LoadOverrideAutoApproveAllow() allowSet := LoadOverrideAutoApproveAllow()
// recommend.allow in scope_overrides.json is intentionally empty: // recommend.allow special-cases scopes absent from scope_priorities.json
// no scopes are special-cased into the auto-approve set anymore. // (application v7 is not in the platform catalog yet) so interactive
if len(allowSet) != 0 { // login's "common scopes" tier still offers them. Only the read scope is
t.Errorf("expected empty override allow set, got %d entries", len(allowSet)) // admitted: write stays out of the recommended tier by design.
if !allowSet["application:app_slash_command:read"] {
t.Error("expected application:app_slash_command:read in override allow set")
}
if allowSet["application:app_slash_command:write"] {
t.Error("write scope must NOT be in the recommended tier")
}
if len(allowSet) != 1 {
t.Errorf("expected exactly 1 override allow entry, got %d", len(allowSet))
} }
} }

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

@@ -12,7 +12,9 @@
"vc:meeting.meetingevent:read": 75 "vc:meeting.meetingevent:read": 75
}, },
"recommend": { "recommend": {
"allow": [], "allow": [
"application:app_slash_command:read"
],
"deny": [ "deny": [
"im:chat", "im:chat",
"im:message.send_as_user" "im:message.send_as_user"

View File

@@ -3,6 +3,10 @@
"en": { "title": "Approval", "description": "Approval instance, and task management" }, "en": { "title": "Approval", "description": "Approval instance, and task management" },
"zh": { "title": "审批", "description": "审批实例、审批任务管理" } "zh": { "title": "审批", "description": "审批实例、审批任务管理" }
}, },
"application": {
"en": { "title": "Application", "description": "Open Platform app self-management: slash commands of the current bound app (NOT Miaoda low-code apps)" },
"zh": { "title": "应用管理", "description": "开放平台应用自管理:当前绑定应用的斜杠指令管理(非妙搭低代码应用)" }
},
"apps": { "apps": {
"en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" }, "en": { "title": "Apps", "description": "Develop, deploy HTML, web pages and applications" },
"zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" } "zh": { "title": "应用", "description": "开发、部署 HTML、Web 页面和应用" }

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

@@ -65,7 +65,7 @@ func safePath(raw, flagName string) (string, error) {
} }
if isAbsolutePath(raw) { if isAbsolutePath(raw) {
return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: use a relative path like ./filename; flags that support stdin can read an out-of-tree file via '-' instead)", flagName, raw) return "", fmt.Errorf("%s must be a relative path within the current directory, got %q (hint: cd to the target directory first, or use a relative path like ./filename)", flagName, raw)
} }
path := filepath.Clean(raw) path := filepath.Clean(raw)

View File

@@ -1,6 +1,6 @@
{ {
"name": "@larksuite/cli", "name": "@larksuite/cli",
"version": "1.0.64", "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

@@ -0,0 +1,18 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
// Package application provides shortcuts for Open Platform app
// self-management (slash commands of the current bound app).
package application
import "github.com/larksuite/cli/shortcuts/common"
// Shortcuts returns all shortcuts of the application domain.
func Shortcuts() []common.Shortcut {
return []common.Shortcut{
SlashCommandList,
SlashCommandCreate,
SlashCommandUpdate,
SlashCommandDelete,
}
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"strings"
"github.com/larksuite/cli/errs"
)
// slashCommandBasePath is the raw v7 endpoint (not in meta_data.json / SDK).
const slashCommandBasePath = "/open-apis/application/v7/app_slash_commands"
// clientCacheHint is printed to stderr after every successful write.
const clientCacheHint = "note: changes take ~5 minutes to appear in Feishu clients (client-side cache); the server state is already updated - list reflects it immediately."
// parseDescriptionI18n parses repeated --description-i18n values ("<lang>=<text>",
// split on the FIRST '='). Returns nil for empty input. Duplicate langs rejected.
func parseDescriptionI18n(values []string) (map[string]string, error) {
if len(values) == 0 {
return nil, nil
}
m := make(map[string]string, len(values))
for _, v := range values {
idx := strings.Index(v, "=")
if idx <= 0 || idx == len(v)-1 {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --description-i18n value %q: expected <lang>=<text> (e.g. zh_cn=你好)", v).
WithParam("--description-i18n")
}
lang := strings.TrimSpace(v[:idx])
text := v[idx+1:]
if lang == "" || strings.TrimSpace(text) == "" {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"invalid --description-i18n value %q: language and text must be non-empty", v).
WithParam("--description-i18n")
}
if _, dup := m[lang]; dup {
return nil, errs.NewValidationError(errs.SubtypeInvalidArgument,
"duplicate language %q in --description-i18n", lang).
WithParam("--description-i18n")
}
m[lang] = text
}
return m, nil
}
// validateCommandName rejects empty and slash-prefixed command names.
func validateCommandName(name, flagName string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s must not be empty", flagName).WithParam(flagName)
}
if strings.HasPrefix(trimmed, "/") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"%s must not start with \"/\" - the slash is implied (use %q)",
flagName, strings.TrimPrefix(trimmed, "/")).WithParam(flagName)
}
return nil
}
// buildSlashCommandBody assembles a create/update request body. Only provided
// fields are included: PATCH is field-level partial (absent top-level fields
// are preserved server-side; a provided i18n map REPLACES the whole map).
// icon sits at the top level, sibling of description (verified live; the
// official create sample nesting icon inside description is a doc bug).
func buildSlashCommandBody(command, description string, i18n map[string]string, iconKey string) map[string]interface{} {
body := map[string]interface{}{}
if command != "" {
body["command"] = command
}
if description != "" || len(i18n) > 0 {
desc := map[string]interface{}{}
if description != "" {
desc["default_value"] = description
}
if len(i18n) > 0 {
desc["i18n"] = i18n
}
body["description"] = desc
}
if iconKey != "" {
body["icon"] = map[string]interface{}{"icon_key": iconKey}
}
return body
}
// isCommandExists reports whether err is the server-side name-collision error
// (code=40000000, message contains "command already exists"; verified live).
func isCommandExists(err error) bool {
p, ok := errs.ProblemOf(err)
if !ok {
return false
}
return strings.Contains(p.Message, "command already exists")
}

View File

@@ -0,0 +1,155 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
func TestParseDescriptionI18n_OK(t *testing.T) {
m, err := parseDescriptionI18n([]string{"zh_cn=你好", "en_us=Hello=World"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if m["zh_cn"] != "你好" {
t.Errorf("zh_cn = %q", m["zh_cn"])
}
// 只按首个 = 分割:值内可含 =
if m["en_us"] != "Hello=World" {
t.Errorf("en_us = %q", m["en_us"])
}
}
func TestParseDescriptionI18n_Empty(t *testing.T) {
m, err := parseDescriptionI18n(nil)
if err != nil || m != nil {
t.Fatalf("nil input: m=%v err=%v", m, err)
}
}
func TestParseDescriptionI18n_BadFormat(t *testing.T) {
for _, bad := range []string{"zh_cn", "=text", "zh_cn=", " =x"} {
_, err := parseDescriptionI18n([]string{bad})
if err == nil {
t.Errorf("%q: expected error", bad)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("%q: expected validation problem, got %v", bad, err)
}
}
}
func TestParseDescriptionI18n_DuplicateLang(t *testing.T) {
_, err := parseDescriptionI18n([]string{"zh_cn=a", "zh_cn=b"})
if err == nil || !strings.Contains(err.Error(), "duplicate language") {
t.Fatalf("expected duplicate language error, got %v", err)
}
}
func TestValidateCommandName(t *testing.T) {
if err := validateCommandName("greet", "--command"); err != nil {
t.Fatalf("greet: %v", err)
}
for _, bad := range []string{"", " ", "/greet"} {
if err := validateCommandName(bad, "--command"); err == nil {
t.Errorf("%q: expected error", bad)
}
}
}
func TestBuildSlashCommandBody(t *testing.T) {
body := buildSlashCommandBody("greet", "hi", map[string]string{"zh_cn": "你好"}, "skill_outlined")
if body["command"] != "greet" {
t.Errorf("command = %v", body["command"])
}
desc := body["description"].(map[string]interface{})
if desc["default_value"] != "hi" {
t.Errorf("default_value = %v", desc["default_value"])
}
if desc["i18n"].(map[string]string)["zh_cn"] != "你好" {
t.Errorf("i18n = %v", desc["i18n"])
}
// icon 与 description 顶层平级(实测钉死,文档 create 示例是笔误)
if body["icon"].(map[string]interface{})["icon_key"] != "skill_outlined" {
t.Errorf("icon = %v", body["icon"])
}
// partial不提供的字段不出现PATCH 语义依赖)
partial := buildSlashCommandBody("", "", nil, "skill_outlined")
if _, has := partial["command"]; has {
t.Error("empty command must be omitted")
}
if _, has := partial["description"]; has {
t.Error("empty description must be omitted")
}
}
// TestSlashCommandShortcuts_SharedScopesAcrossIdentities locks in the
// reversal of the OAuth-isolation design: all four slash-command shortcuts
// declare identical scopes for the bot and user identities (plain Scopes /
// ConditionalScopes, no per-identity overrides), so a user-identity
// pre-flight sees the same scope set a bot identity would.
func TestSlashCommandShortcuts_SharedScopesAcrossIdentities(t *testing.T) {
cases := []struct {
name string
shortcut common.Shortcut
wantScope string
wantConditional string
hasConditional bool
}{
{
name: "list",
shortcut: SlashCommandList,
wantScope: "application:app_slash_command:read",
},
{
name: "create",
shortcut: SlashCommandCreate,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
{
name: "update",
shortcut: SlashCommandUpdate,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
{
name: "delete",
shortcut: SlashCommandDelete,
wantScope: "application:app_slash_command:write",
wantConditional: "application:app_slash_command:read",
hasConditional: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
for _, identity := range []string{"user", "bot"} {
declared := tc.shortcut.DeclaredScopesForIdentity(identity)
if !containsStr(declared, tc.wantScope) {
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain %q", tc.name, identity, declared, tc.wantScope)
}
if tc.hasConditional && !containsStr(declared, tc.wantConditional) {
t.Errorf("%s: DeclaredScopesForIdentity(%q) = %v, want to contain conditional %q", tc.name, identity, declared, tc.wantConditional)
}
}
})
}
}
func containsStr(list []string, want string) bool {
for _, v := range list {
if v == want {
return true
}
}
return false
}

View File

@@ -0,0 +1,117 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandCreate registers a new slash command on the current bound app.
var SlashCommandCreate = common.Shortcut{
Service: "application",
Command: "+slash-command-create",
Description: "Register a slash command (/ command) on the current bound Open Platform app; --force converts a name collision into an update (idempotent re-run)",
Risk: "write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --force collision path lists to resolve the id
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command", Desc: "command name WITHOUT the leading slash (server enforces uniqueness per app; max 100 commands)", Required: true},
{Name: "description", Desc: "default description shown in the client command panel (description.default_value)", Required: true},
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable, format <lang>=<text> (e.g. zh_cn=发送问候); language codes are passed through to the server"},
{Name: "icon-key", Desc: "icon key (server default: skill_outlined; invalid keys are rejected server-side with code 40000031)"},
{Name: "force", Type: "bool", Desc: "on name collision, resolve the existing command by name and PATCH it instead (like `gh label create --force`)"},
},
Tips: []string{
`lark-cli application +slash-command-create --command greet --description "say hi" --description-i18n zh_cn=问候 --as bot`,
"changes take ~5 minutes to appear in clients (client-side cache); the server updates immediately",
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:write",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
if err := validateCommandName(runtime.Str("command"), "--command"); err != nil {
return err
}
if len(strings.TrimSpace(runtime.Str("description"))) == 0 {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description must not be blank").WithParam("--description")
}
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
return err
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
body := buildSlashCommandBody(runtime.Str("command"), runtime.Str("description"), i18n, runtime.Str("icon-key"))
d := common.NewDryRunAPI().
Desc("Create a slash command on the current bound app").
POST(slashCommandBasePath).
Body(body)
if runtime.Bool("force") {
d.Desc("--force: on 'command already exists' (code 40000000), GET list to resolve command_id then PATCH the same body")
}
return d
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
name := runtime.Str("command")
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return err
}
body := buildSlashCommandBody(name, runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err := runtime.CallAPITyped("POST", slashCommandBasePath, nil, body)
action := "created"
if err != nil {
if !isCommandExists(err) {
return err
}
if !runtime.Bool("force") {
p, _ := errs.ProblemOf(err)
rewrapped := errs.NewAPIError(p.Subtype, "slash command %q already exists", name).
WithHint("rerun with --force to update it, or use `lark-cli application +slash-command-update --command %q`", name).
WithCause(err)
if p.Code != 0 {
rewrapped = rewrapped.WithCode(p.Code)
}
if p.LogID != "" {
rewrapped = rewrapped.WithLogID(p.LogID)
}
return rewrapped
}
// --force: name collision -> resolve id -> PATCH (idempotent re-run).
id, _, rerr := resolveCommandID(runtime, name)
if rerr != nil {
return rerr
}
patchBody := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err = runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+validate.EncodePathSegment(id), nil, patchBody)
if err != nil {
return err
}
action = "updated"
}
if data == nil {
data = map[string]interface{}{}
}
data["action"] = action
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "%s /%v (command_id: %v)\n", action, data["command"], data["command_id"])
})
return nil
},
}

View File

@@ -0,0 +1,182 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"errors"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func createOKStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": sampleItem("greet", "id-new"),
},
}
}
func createConflictStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 40000000, "msg": "Invalid Param 'command'. command already exists.",
},
}
}
func patchOKStub(id string) *httpmock.Stub {
return &httpmock.Stub{
Method: "PATCH",
URL: "/open-apis/application/v7/app_slash_commands/" + id,
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": sampleItem("greet", id),
},
}
}
func TestSlashCommandCreate_OK(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createOKStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi",
"--description-i18n", "zh_cn=你好", "--description-i18n", "en_us=Hello",
"--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\n%s", err, stdout.String())
}
data := got["data"].(map[string]interface{})
if data["action"] != "created" {
t.Fatalf("action = %v", data["action"])
}
if data["command_id"] != "id-new" {
t.Fatalf("command_id = %v", data["command_id"])
}
}
func TestSlashCommandCreate_ValidateRejects(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
cases := [][]string{
{"+slash-command-create", "--command", "/greet", "--description", "hi", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "bad", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", "hi", "--description-i18n", "zh_cn=a", "--description-i18n", "zh_cn=b", "--as", "bot"},
{"+slash-command-create", "--command", "greet", "--description", " ", "--as", "bot"},
}
for i, args := range cases {
err := mountAndRun(t, SlashCommandCreate, args, f, stdout)
if err == nil {
t.Errorf("case %d: expected validation error", i)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("case %d: expected validation problem, got %v", i, err)
}
}
}
func TestSlashCommandCreate_ConflictNoForce(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createConflictStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected conflict error")
}
p, _ := errs.ProblemOf(err)
if !strings.Contains(p.Hint, "--force") || !strings.Contains(p.Hint, "+slash-command-update") {
t.Fatalf("hint must offer --force and update, got %q", p.Hint)
}
var apiErr *errs.APIError
if !errors.As(err, &apiErr) {
t.Fatalf("rewrapped error must be *errs.APIError, got %T", err)
}
if errors.Unwrap(apiErr) == nil {
t.Fatal("rewrapped conflict error must preserve the original cause via WithCause")
}
}
func TestSlashCommandCreate_ForceConvertsToUpdate(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createConflictStub())
reg.Register(listStub([]interface{}{sampleItem("greet", "id-exist")}))
reg.Register(patchOKStub("id-exist"))
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi2", "--force", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
if data["action"] != "updated" {
t.Fatalf("action = %v (force must convert to update)", data["action"])
}
}
func createIconInvalidStub() *httpmock.Stub {
return &httpmock.Stub{
Method: "POST",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 40000031, "msg": "Invalid Param 'icon_key'. icon_key is invalid.",
},
}
}
// TestSlashCommandCreate_ForceDoesNotConvertNonConflict guards against --force
// blindly treating ANY POST failure as a name collision: only the
// "command already exists" (40000000) shape may fall through to the
// GET+PATCH idempotent-update path. No PATCH stub is registered here, so if
// the code mistakenly attempted a PATCH, the httpmock registry would fail
// the unexpected request and surface a different (registry) error instead
// of the original icon_key failure asserted below.
func TestSlashCommandCreate_ForceDoesNotConvertNonConflict(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(createIconInvalidStub())
err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--icon-key", "bogus", "--force", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected the original icon_key error, got nil")
}
if !strings.Contains(err.Error(), "icon_key") {
t.Fatalf("expected original icon_key failure to surface unchanged, got %v", err)
}
}
func TestSlashCommandCreate_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
if err := mountAndRun(t, SlashCommandCreate, []string{"+slash-command-create",
"--command", "greet", "--description", "hi", "--icon-key", "skill_outlined", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "POST") || !strings.Contains(out, slashCommandBasePath) {
t.Fatalf("dry-run must show POST path: %s", out)
}
// icon 顶层dry-run body 里 icon 不嵌套在 description 内
if !strings.Contains(out, "icon_key") {
t.Fatalf("dry-run must include body: %s", out)
}
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandDelete removes a slash command (irreversible; command_id is not
// reused - recreating the same name yields a NEW id).
var SlashCommandDelete = common.Shortcut{
Service: "application",
Command: "+slash-command-delete",
Description: "Delete a slash command from the current bound app (high-risk: irreversible; recreating the same name yields a new command_id)",
Risk: "high-risk-write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --command by-name path
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command-id", Desc: "target command_id; mutually exclusive with --command"},
{Name: "command", Desc: "target command name WITHOUT leading slash (resolved via live list, needs read scope); mutually exclusive with --command-id"},
},
Tips: []string{
"lark-cli application +slash-command-delete --command greet --yes --as bot",
"deleted commands may linger in clients for ~5 minutes (client cache)",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if (id == "") == (name == "") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide exactly one of --command-id or --command").WithParam("--command-id")
}
if name != "" {
return validateCommandName(name, "--command")
}
return nil
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
d := common.NewDryRunAPI().Desc("HIGH-RISK: delete a slash command (irreversible; same-name recreate gets a NEW command_id)")
target := runtime.Str("command-id")
if target == "" {
d.GET(slashCommandBasePath).
Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", runtime.Str("command")))
target = "<resolved_command_id>"
}
return d.DELETE(slashCommandBasePath + "/" + target)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if id == "" {
resolved, _, err := resolveCommandID(runtime, name)
if err != nil {
return err
}
id = resolved
}
if _, err := runtime.CallAPITyped("DELETE", slashCommandBasePath+"/"+validate.EncodePathSegment(id), nil, nil); err != nil {
return err
}
out := map[string]interface{}{"action": "deleted", "command_id": id}
if name != "" {
out["command"] = name
}
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
fmt.Fprintln(runtime.IO().ErrOut, "note: recreating the same command name will yield a NEW command_id.")
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "deleted command_id %s\n", id)
})
return nil
},
}

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/httpmock"
)
func deleteOKStub(id string) *httpmock.Stub {
return &httpmock.Stub{
Method: "DELETE",
URL: slashCommandBasePath + "/" + id,
Body: map[string]interface{}{"code": 0, "msg": "success", "data": map[string]interface{}{}},
}
}
func TestSlashCommandDelete_RequiresYes(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command-id", "id1", "--as", "bot"}, f, stdout)
if err == nil {
t.Fatal("expected confirmation_required without --yes")
}
if errs.CategoryOf(err) != errs.CategoryConfirmation {
t.Fatalf("expected confirmation category, got %v (%v)", errs.CategoryOf(err), err)
}
}
func TestSlashCommandDelete_ByIDWithYes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(deleteOKStub("id1"))
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command-id", "id1", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
// 上游 DELETE 返回空对象CLI 必须补 action/command_id写操作返回资源 ID
if data["action"] != "deleted" || data["command_id"] != "id1" {
t.Fatalf("data = %v", data)
}
}
func TestSlashCommandDelete_ByNameWithYes(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id7")}))
reg.Register(deleteOKStub("id7"))
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command", "greet", "--yes", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
_ = json.Unmarshal(stdout.Bytes(), &got)
data := got["data"].(map[string]interface{})
if data["command"] != "greet" || data["command_id"] != "id7" {
t.Fatalf("data = %v", data)
}
}
func TestSlashCommandDelete_ByNameDryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
err := mountAndRun(t, SlashCommandDelete, []string{"+slash-command-delete",
"--command", "greet", "--dry-run", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
// 两条 Desc 都必须保留top-level HIGH-RISK 说明和 GET 调用的 resolve 说明
// 不能被覆盖DryRunAPI.Desc 在没有 call 时设置 top-levelappend 后设置 per-call
if !strings.Contains(out, "HIGH-RISK") {
t.Fatalf("dry-run must keep top-level HIGH-RISK desc: %s", out)
}
if !strings.Contains(out, "resolve command_id") {
t.Fatalf("dry-run must keep per-call resolve desc: %s", out)
}
}
func TestSlashCommandDelete_Validate(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
for _, args := range [][]string{
{"+slash-command-delete", "--yes", "--as", "bot"},
{"+slash-command-delete", "--command-id", "id1", "--command", "greet", "--yes", "--as", "bot"},
} {
err := mountAndRun(t, SlashCommandDelete, args, f, stdout)
if err == nil {
t.Errorf("%v: expected validation error", args)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("%v: expected validation problem, got %v", args, err)
}
}
}

View File

@@ -0,0 +1,58 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"github.com/larksuite/cli/shortcuts/common"
)
// SlashCommandList lists all slash commands of the current bound app.
var SlashCommandList = common.Shortcut{
Service: "application",
Command: "+slash-command-list",
Description: "List all slash commands (/ commands) registered on the current bound Open Platform app; source of command_id for update/delete (NOT for Miaoda apps - use the apps domain for those)",
Risk: "read",
Scopes: []string{"application:app_slash_command:read"},
AuthTypes: []string{"bot", "user"},
Tips: []string{
"lark-cli application +slash-command-list --as bot",
"user identity needs explicit authorization first: lark-cli auth login --scope application:app_slash_command:read",
"the upstream API returns all commands at once (max 100 per app, no pagination)",
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
return common.NewDryRunAPI().
Desc("List all slash commands of the current bound app (read-only)").
GET(slashCommandBasePath)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
if err != nil {
return err
}
items, _ := data["items"].([]interface{})
if items == nil {
items = []interface{}{}
}
out := map[string]interface{}{"items": items, "count": len(items)}
runtime.OutFormat(out, nil, func(w io.Writer) {
fmt.Fprintf(w, "%d slash command(s)\n", len(items))
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
desc := ""
if d, ok := m["description"].(map[string]interface{}); ok {
desc, _ = d["default_value"].(string)
}
fmt.Fprintf(w, " /%v\t%v\t%s\n", m["command"], m["command_id"], desc)
}
})
return nil
},
}

View File

@@ -0,0 +1,115 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/httpmock"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func appTestConfig() *core.CliConfig {
return &core.CliConfig{AppID: "test-app", AppSecret: "test-secret", Brand: core.BrandFeishu}
}
// mountAndRun mounts the shortcut under a parent cobra command and runs it.
// Mirrors shortcuts/contact tests.
func mountAndRun(t *testing.T, s common.Shortcut, args []string, f *cmdutil.Factory, stdout *bytes.Buffer) error {
t.Helper()
parent := &cobra.Command{Use: "application"}
s.Mount(parent, f)
parent.SetArgs(args)
parent.SilenceErrors = true
parent.SilenceUsage = true
if stdout != nil {
stdout.Reset()
}
return parent.Execute()
}
func listStub(items []interface{}) *httpmock.Stub {
return &httpmock.Stub{
Method: "GET",
URL: "/open-apis/application/v7/app_slash_commands",
Body: map[string]interface{}{
"code": 0, "msg": "success",
"data": map[string]interface{}{"items": items},
},
}
}
func sampleItem(name, id string) map[string]interface{} {
return map[string]interface{}{
"command": name, "command_id": id,
"create_time": "1783318553", "update_time": "1783318553",
"description": map[string]interface{}{"default_value": "desc of " + name},
"icon": map[string]interface{}{"icon_key": "skill_outlined"},
}
}
func TestSlashCommandList_JSON(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id1"), sampleItem("weather", "id2")}))
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v\n%s", err, stdout.String())
}
data := got["data"].(map[string]interface{})
items := data["items"].([]interface{})
if len(items) != 2 {
t.Fatalf("items = %d", len(items))
}
if data["count"] != float64(2) {
t.Fatalf("count = %v", data["count"])
}
first := items[0].(map[string]interface{})
for _, k := range []string{"command", "command_id", "description", "icon", "create_time", "update_time"} {
if _, ok := first[k]; !ok {
t.Errorf("missing item key %q", k)
}
}
}
func TestSlashCommandList_Empty(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub(nil))
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--format", "json", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
items, ok := data["items"].([]interface{})
if !ok || len(items) != 0 {
t.Fatalf("empty list must be [] not %v", data["items"])
}
if data["count"] != float64(0) {
t.Fatalf("count = %v", data["count"])
}
}
func TestSlashCommandList_DryRun(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
if err := mountAndRun(t, SlashCommandList, []string{"+slash-command-list", "--dry-run", "--as", "bot"}, f, stdout); err != nil {
t.Fatalf("execute: %v", err)
}
out := stdout.String()
if !strings.Contains(out, "/open-apis/application/v7/app_slash_commands") || !strings.Contains(out, "GET") {
t.Fatalf("dry-run must show GET path, got %s", out)
}
}

View File

@@ -0,0 +1,53 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
)
// matchCommandItem finds the item whose "command" equals name (exact match -
// the server enforces name uniqueness, so first hit is the only hit).
func matchCommandItem(items []interface{}, name string) (string, map[string]interface{}) {
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
if m["command"] == name {
id, _ := m["command_id"].(string)
if id != "" {
return id, m
}
}
}
return "", nil
}
// commandNotFoundError reports a resolution miss against the live list as an
// API-category not-found error (the name is a valid argument shape; the
// resource simply does not exist server-side - this is not a validation
// failure of caller input).
func commandNotFoundError(name string) error {
return errs.NewAPIError(errs.SubtypeNotFound,
"slash command %q not found in the current bound app", name).
WithHint("run `lark-cli application +slash-command-list` to see registered commands")
}
// resolveCommandID resolves a command name to its command_id via the live
// list endpoint (in-memory only; never touches local files). Requires the
// read scope on the current identity.
func resolveCommandID(runtime *common.RuntimeContext, name string) (string, map[string]interface{}, error) {
data, err := runtime.CallAPITyped("GET", slashCommandBasePath, nil, nil)
if err != nil {
return "", nil, err
}
items, _ := data["items"].([]interface{})
id, item := matchCommandItem(items, name)
if id == "" {
return "", nil, commandNotFoundError(name)
}
return id, item, nil
}

View File

@@ -0,0 +1,39 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"strings"
"testing"
)
func TestMatchCommandItem(t *testing.T) {
items := []interface{}{
sampleItem("greet", "id1"),
sampleItem("weather", "id2"),
}
id, item := matchCommandItem(items, "weather")
if id != "id2" || item == nil {
t.Fatalf("got id=%q item=%v", id, item)
}
id, item = matchCommandItem(items, "nope")
if id != "" || item != nil {
t.Fatalf("miss should return empty, got id=%q", id)
}
// 精确匹配:大小写与空白不做宽容
id, _ = matchCommandItem(items, "Greet")
if id != "" {
t.Fatalf("match must be exact, got %q", id)
}
}
func TestResolveNotFoundErrorShape(t *testing.T) {
err := commandNotFoundError("nope")
if err == nil || !strings.Contains(err.Error(), `"nope"`) {
t.Fatalf("err = %v", err)
}
if !strings.Contains(err.Error(), "not found") {
t.Fatalf("err should say not found: %v", err)
}
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"context"
"fmt"
"io"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/validate"
"github.com/larksuite/cli/shortcuts/common"
)
// validateUpdateTarget enforces: exactly one of --command-id/--command, and at
// least one editable field; --description-i18n requires --description (PATCH
// replaces the whole description object - sending i18n alone would drop
// default_value; conservative rule, see spec amendment #3).
func validateUpdateTarget(runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
name := strings.TrimSpace(runtime.Str("command"))
if (id == "") == (name == "") {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide exactly one of --command-id or --command").WithParam("--command-id")
}
if name != "" {
if err := validateCommandName(name, "--command"); err != nil {
return err
}
}
hasDesc := strings.TrimSpace(runtime.Str("description")) != ""
hasI18n := len(runtime.StrArray("description-i18n")) > 0
hasIcon := strings.TrimSpace(runtime.Str("icon-key")) != ""
if !hasDesc && !hasI18n && !hasIcon {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"provide at least one of --description / --description-i18n / --icon-key").WithParam("--description")
}
if hasI18n && !hasDesc {
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"--description-i18n requires --description: PATCH replaces the whole description object, so default_value must be provided together").WithParam("--description-i18n")
}
if _, err := parseDescriptionI18n(runtime.StrArray("description-i18n")); err != nil {
return err
}
return nil
}
// SlashCommandUpdate updates description/i18n/icon of an existing slash command.
var SlashCommandUpdate = common.Shortcut{
Service: "application",
Command: "+slash-command-update",
Description: "Update description / localized descriptions / icon of a slash command on the current bound app, addressed by --command-id or by name via --command",
Risk: "write",
Scopes: []string{"application:app_slash_command:write"},
ConditionalScopes: []string{
"application:app_slash_command:read", // only the --command by-name path lists to resolve the id
},
AuthTypes: []string{"bot", "user"},
Flags: []common.Flag{
{Name: "command-id", Desc: "target command_id (from +slash-command-list or create output); mutually exclusive with --command"},
{Name: "command", Desc: "target command name WITHOUT leading slash; resolved via live list (needs read scope); mutually exclusive with --command-id"},
{Name: "description", Desc: "new default description (description.default_value)"},
{Name: "description-i18n", Type: "string_array", Desc: "localized description, repeatable <lang>=<text>; REPLACES the whole i18n map (missing languages are dropped); requires --description"},
{Name: "icon-key", Desc: "new icon key (invalid keys rejected server-side with code 40000031)"},
},
Tips: []string{
`lark-cli application +slash-command-update --command greet --description "new text" --as bot`,
"PATCH is field-level partial: fields you do not pass are preserved server-side",
"the command NAME itself cannot be changed (API limitation): rename = delete + create (new command_id)",
},
Validate: func(ctx context.Context, runtime *common.RuntimeContext) error {
return validateUpdateTarget(runtime)
},
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return common.NewDryRunAPI().Set("error", err.Error())
}
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
d := common.NewDryRunAPI()
target := runtime.Str("command-id")
if target == "" {
d.Desc(fmt.Sprintf("resolve command_id by name %q via GET list first", runtime.Str("command"))).
GET(slashCommandBasePath)
target = "<resolved_command_id>"
}
return d.PATCH(slashCommandBasePath + "/" + target).Body(body)
},
Execute: func(ctx context.Context, runtime *common.RuntimeContext) error {
id := strings.TrimSpace(runtime.Str("command-id"))
if id == "" {
resolved, _, err := resolveCommandID(runtime, strings.TrimSpace(runtime.Str("command")))
if err != nil {
return err
}
id = resolved
}
i18n, err := parseDescriptionI18n(runtime.StrArray("description-i18n"))
if err != nil {
return err
}
body := buildSlashCommandBody("", runtime.Str("description"), i18n, runtime.Str("icon-key"))
data, err := runtime.CallAPITyped("PATCH", slashCommandBasePath+"/"+validate.EncodePathSegment(id), nil, body)
if err != nil {
return err
}
if data == nil {
data = map[string]interface{}{}
}
data["action"] = "updated"
fmt.Fprintln(runtime.IO().ErrOut, clientCacheHint)
runtime.OutFormat(data, nil, func(w io.Writer) {
fmt.Fprintf(w, "updated /%v (command_id: %v)\n", data["command"], data["command_id"])
})
return nil
},
}

View File

@@ -0,0 +1,79 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package application
import (
"encoding/json"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/cmdutil"
)
func TestSlashCommandUpdate_ByID(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(patchOKStub("id1"))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command-id", "id1", "--description", "new", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
var got map[string]interface{}
if err := json.Unmarshal(stdout.Bytes(), &got); err != nil {
t.Fatalf("json: %v", err)
}
data := got["data"].(map[string]interface{})
if data["action"] != "updated" {
t.Fatalf("action = %v", data["action"])
}
}
func TestSlashCommandUpdate_ByName(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub([]interface{}{sampleItem("greet", "id9")}))
reg.Register(patchOKStub("id9"))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command", "greet", "--icon-key", "skill_outlined", "--format", "json", "--as", "bot"}, f, stdout)
if err != nil {
t.Fatalf("execute: %v", err)
}
}
func TestSlashCommandUpdate_ByNameNotFound(t *testing.T) {
f, stdout, _, reg := cmdutil.TestFactory(t, appTestConfig())
reg.Register(listStub(nil))
err := mountAndRun(t, SlashCommandUpdate, []string{"+slash-command-update",
"--command", "nope", "--description", "x", "--as", "bot"}, f, stdout)
if err == nil || !strings.Contains(err.Error(), "not found") {
t.Fatalf("expected not found, got %v", err)
}
}
func TestSlashCommandUpdate_Validate(t *testing.T) {
f, stdout, _, _ := cmdutil.TestFactory(t, appTestConfig())
cases := []struct {
name string
args []string
}{
{"both id and name", []string{"+slash-command-update", "--command-id", "id1", "--command", "greet", "--description", "x", "--as", "bot"}},
{"neither id nor name", []string{"+slash-command-update", "--description", "x", "--as", "bot"}},
{"no editable field", []string{"+slash-command-update", "--command-id", "id1", "--as", "bot"}},
{"i18n without description", []string{"+slash-command-update", "--command-id", "id1", "--description-i18n", "zh_cn=x", "--as", "bot"}},
}
for _, c := range cases {
err := mountAndRun(t, SlashCommandUpdate, c.args, f, stdout)
if err == nil {
t.Errorf("%s: expected validation error", c.name)
continue
}
p, ok := errs.ProblemOf(err)
if !ok || p.Category != errs.CategoryValidation {
t.Errorf("%s: expected validation problem, got %v", c.name, err)
}
}
}

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

@@ -1068,7 +1068,7 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
if rctx.stdinConsumed { if rctx.stdinConsumed {
return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name). return ValidationErrorf("--%s: stdin (-) can only be used by one flag", fl.Name).
WithParam("--"+fl.Name). WithParam("--"+fl.Name).
WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others inline or as @file with a relative path under the current directory (e.g. --%s @./payload.json)", fl.Name) WithHint("a process has a single stdin, so only one flag per call may use '-'; pass the others as @file (e.g. --%s @/path/to/file)", fl.Name)
} }
rctx.stdinConsumed = true rctx.stdinConsumed = true
data, err := io.ReadAll(rctx.IO().In) data, err := io.ReadAll(rctx.IO().In)
@@ -1102,16 +1102,9 @@ func resolveInputFlags(rctx *RuntimeContext, flags []Flag) error {
} }
data, err := cmdutil.ReadInputFile(rctx.FileIO(), path) data, err := cmdutil.ReadInputFile(rctx.FileIO(), path)
if err != nil { if err != nil {
verr := ValidationErrorf("--%s: %v", fl.Name, err). return ValidationErrorf("--%s: %v", fl.Name, err).
WithParam("--" + fl.Name). WithParam("--" + fl.Name).
WithCause(err) WithCause(err)
if slices.Contains(fl.Input, Stdin) {
// Rejected @file paths are usually absolute (temp files under
// /tmp). Steer toward stdin rather than cd / copying the file
// into the project tree.
verr = verr.WithHint("this flag also reads stdin: --%s - < %s", fl.Name, path)
}
return verr
} }
// strip a leading UTF-8 BOM so it // strip a leading UTF-8 BOM so it
// can't corrupt the first CSV cell or break JSON parsing downstream. // can't corrupt the first CSV cell or break JSON parsing downstream.

View File

@@ -227,35 +227,6 @@ func TestResolveInputFlags_DuplicateStdin(t *testing.T) {
} }
} }
// TestResolveInputFlags_FileErrorSuggestsStdin pins the recovery hint when
// an @file path is rejected (typically an absolute /tmp path): flags that
// also accept stdin must point at `--flag - < path` — never at cd'ing into
// the target directory or copying the file into the project tree.
func TestResolveInputFlags_FileErrorSuggestsStdin(t *testing.T) {
rctx := newTestRuntimeWithStdin(map[string]string{"csv": "@/tmp/does-not-exist.csv"}, "")
flags := []Flag{{Name: "csv", Input: []string{File, Stdin}}}
err := resolveInputFlags(rctx, flags)
if err == nil {
t.Fatal("expected error for rejected @file path")
}
vErr := assertValidationParam(t, err, "--csv")
if !strings.Contains(vErr.Hint, "--csv - < /tmp/does-not-exist.csv") {
t.Errorf("hint %q should show the stdin form of the same call", vErr.Hint)
}
// A flag without stdin support must not get the stdin hint.
rctx = newTestRuntimeWithStdin(map[string]string{"file": "@/tmp/does-not-exist.xlsx"}, "")
err = resolveInputFlags(rctx, []Flag{{Name: "file", Input: []string{File}}})
if err == nil {
t.Fatal("expected error for rejected @file path")
}
vErr = assertValidationParam(t, err, "--file")
if strings.Contains(vErr.Hint, "stdin") {
t.Errorf("hint %q must not suggest stdin for a file-only flag", vErr.Hint)
}
}
func TestStripUTF8BOM(t *testing.T) { func TestStripUTF8BOM(t *testing.T) {
cases := []struct{ name, in, want string }{ cases := []struct{ name, in, want string }{
{"leading BOM removed", "\uFEFFhello", "hello"}, {"leading BOM removed", "\uFEFFhello", "hello"},

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

@@ -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

@@ -17,6 +17,7 @@ import (
"github.com/larksuite/cli/internal/core" "github.com/larksuite/cli/internal/core"
"github.com/larksuite/cli/internal/deprecation" "github.com/larksuite/cli/internal/deprecation"
"github.com/larksuite/cli/internal/registry" "github.com/larksuite/cli/internal/registry"
"github.com/larksuite/cli/shortcuts/application"
"github.com/larksuite/cli/shortcuts/apps" "github.com/larksuite/cli/shortcuts/apps"
"github.com/larksuite/cli/shortcuts/base" "github.com/larksuite/cli/shortcuts/base"
"github.com/larksuite/cli/shortcuts/calendar" "github.com/larksuite/cli/shortcuts/calendar"
@@ -61,6 +62,7 @@ var allShortcuts []common.Shortcut
func init() { func init() {
allShortcuts = append(allShortcuts, apps.Shortcuts()...) allShortcuts = append(allShortcuts, apps.Shortcuts()...)
allShortcuts = append(allShortcuts, application.Shortcuts()...)
allShortcuts = append(allShortcuts, calendar.Shortcuts()...) allShortcuts = append(allShortcuts, calendar.Shortcuts()...)
allShortcuts = append(allShortcuts, doc.Shortcuts()...) allShortcuts = append(allShortcuts, doc.Shortcuts()...)
allShortcuts = append(allShortcuts, drive.Shortcuts()...) allShortcuts = append(allShortcuts, drive.Shortcuts()...)
@@ -205,18 +207,16 @@ func installBrandRestrictionGuard(svc *cobra.Command, service string, brand core
svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand) svc.Long = fmt.Sprintf("The %q feature is not yet supported on the %s brand.", service, brand)
} }
// Sheets backward-compatibility grouping. // Sheets backward-compatibility help grouping.
// //
// shortcuts/sheets/backward keeps the pre-refactor command names alive so that // shortcuts/sheets/backward keeps the pre-refactor command names alive so that
// users whose lark-sheets skill predates the refactor keep working even after // users whose lark-sheets skill predates the refactor keep working even after
// upgrading only the binary. applySheetsCompatGroups tags each alias into a // upgrading only the binary. In `sheets --help` those aliases would otherwise
// dedicated deprecated cobra group. The refactored commands have been the // sort alphabetically into the same flat list as the current commands,
// default for over a month, so `sheets --help` no longer lists these aliases: // indistinguishable from them. applySheetsCompatGroups splits them into a
// sheetsUsageTemplate renders every group except the deprecated one. The // dedicated cobra group whose heading tells the user to update their skill, and
// grouping is still applied for two reasons — the unknown-subcommand path // appends a "(→ +new-command)" pointer to each alias so the migration target is
// (cmd/root.go) keys off it to classify a mistyped legacy alias, and each // obvious. Pure presentation — the aliases stay fully executable.
// alias's own `sheets <alias> --help` still surfaces the "(→ +new-command)"
// migration pointer appended below. The aliases stay fully executable.
const ( const (
sheetsCurrentGroupID = "sheets-current" sheetsCurrentGroupID = "sheets-current"
// sheetsDeprecatedGroupID aliases the shared deprecated-group id so both // sheetsDeprecatedGroupID aliases the shared deprecated-group id so both
@@ -226,10 +226,9 @@ const (
) )
// sheetsAliasReplacement maps each pre-refactor sheets alias to the current // sheetsAliasReplacement maps each pre-refactor sheets alias to the current
// command(s) that replace it, shown as a "(→ ...)" suffix in the alias's own // command(s) that replace it, shown as a "(→ ...)" suffix in --help. Aliases
// --help and reused by wrapSheetsBackwardDeprecation for the on-execution // absent from this map still land in the deprecated group, just without a
// _notice. Aliases absent from this map still land in the deprecated group, // pointer, so a missing entry degrades gracefully rather than misgrouping.
// just without a pointer, so a missing entry degrades gracefully.
var sheetsAliasReplacement = map[string]string{ var sheetsAliasReplacement = map[string]string{
// spreadsheet / sheet management // spreadsheet / sheet management
"+create": "+workbook-create", "+create": "+workbook-create",
@@ -282,43 +281,6 @@ var sheetsAliasReplacement = map[string]string{
"+delete-float-image": "+float-image-delete", "+delete-float-image": "+float-image-delete",
} }
// sheetsUsageTemplate is cobra v1.10.2's stock usage template with a single
// change: the group loop is guarded by {{if ne $group.ID "deprecated"}} so the
// deprecated pre-refactor aliases are omitted from `sheets --help` altogether.
// Everything else — current commands, ungrouped metaapi subcommands under
// "Additional Commands", flags — renders exactly as cobra's default. Keep in
// sync with cobra's defaultUsageTemplate on upgrade.
var sheetsUsageTemplate = fmt.Sprintf(`Usage:{{if .Runnable}}
{{.UseLine}}{{end}}{{if .HasAvailableSubCommands}}
{{.CommandPath}} [command]{{end}}{{if gt (len .Aliases) 0}}
Aliases:
{{.NameAndAliases}}{{end}}{{if .HasExample}}
Examples:
{{.Example}}{{end}}{{if .HasAvailableSubCommands}}{{$cmds := .Commands}}{{if eq (len .Groups) 0}}
Available Commands:{{range $cmds}}{{if (or .IsAvailableCommand (eq .Name "help"))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{else}}{{range $group := .Groups}}{{if ne $group.ID %q}}
{{.Title}}{{range $cmds}}{{if (and (eq .GroupID $group.ID) (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{if not .AllChildCommandsHaveGroup}}
Additional Commands:{{range $cmds}}{{if (and (eq .GroupID "") (or .IsAvailableCommand (eq .Name "help")))}}
{{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}}{{end}}{{end}}{{end}}{{if .HasAvailableLocalFlags}}
Flags:
{{.LocalFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasAvailableInheritedFlags}}
Global Flags:
{{.InheritedFlags.FlagUsages | trimTrailingWhitespaces}}{{end}}{{if .HasHelpSubCommands}}
Additional help topics:{{range .Commands}}{{if .IsAdditionalHelpTopicCommand}}
{{rpad .CommandPath .CommandPathPadding}} {{.Short}}{{end}}{{end}}{{end}}{{if .HasAvailableSubCommands}}
Use "{{.CommandPath}} [command] --help" for more information about a command.{{end}}
`, sheetsDeprecatedGroupID)
func applySheetsCompatGroups(svc *cobra.Command) { func applySheetsCompatGroups(svc *cobra.Command) {
svc.AddGroup( svc.AddGroup(
&cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"}, &cobra.Group{ID: sheetsCurrentGroupID, Title: "Available Commands:"},
@@ -350,11 +312,6 @@ func applySheetsCompatGroups(svc *cobra.Command) {
c.GroupID = sheetsCurrentGroupID c.GroupID = sheetsCurrentGroupID
} }
} }
// Refactored commands have been the default for over a month: drop the
// deprecated group from `sheets --help` (see sheetsUsageTemplate). The
// aliases remain grouped and executable, just no longer advertised here.
svc.SetUsageTemplate(sheetsUsageTemplate)
} }
// wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets // wrapSheetsBackwardDeprecation decorates each backward-compatibility sheets

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))
@@ -511,11 +532,10 @@ func TestApplySheetsCompatGroups(t *testing.T) {
} }
} }
// End-to-end: `sheets --help` must list refactored shortcuts under Available // End-to-end: the rendered `sheets --help` must surface the deprecated-group
// Commands, but no longer advertise the deprecated pre-refactor aliases or the // heading (telling users to update their skill) plus the per-alias migration
// deprecated group heading (sheetsUsageTemplate skips that group). The aliases // pointers, while keeping the refactored shortcuts under Available Commands.
// stay registered and executable — hidden from the parent listing, not removed. func TestRegisterShortcutsSheetsHelpGroupsDeprecatedAliases(t *testing.T) {
func TestRegisterShortcutsSheetsHelpHidesDeprecatedAliases(t *testing.T) {
program := &cobra.Command{Use: "root"} program := &cobra.Command{Use: "root"}
RegisterShortcuts(program, newRegisterTestFactory(t)) RegisterShortcuts(program, newRegisterTestFactory(t))
@@ -531,25 +551,19 @@ func TestRegisterShortcutsSheetsHelpHidesDeprecatedAliases(t *testing.T) {
} }
got := out.String() got := out.String()
for _, want := range []string{"Available Commands:", "+cells-get"} { for _, want := range []string{
if !strings.Contains(got, want) { "Available Commands:",
t.Fatalf("sheets help missing %q:\n%s", want, got)
}
}
for _, unwanted := range []string{
"Deprecated pre-refactor commands", "Deprecated pre-refactor commands",
"update your lark-sheets skill", "update your lark-sheets skill",
"+read", "+read",
"(→ +cells-get)",
"+write", "+write",
"(→ +cells-set)",
} { } {
if strings.Contains(got, unwanted) { if !strings.Contains(got, want) {
t.Fatalf("sheets help still shows deprecated content %q:\n%s", unwanted, got) t.Fatalf("sheets help missing %q:\n%s", want, got)
} }
} }
if alias, _, ferr := sheetsCmd.Find([]string{"+read"}); ferr != nil || alias == nil {
t.Fatalf("deprecated alias +read should stay registered, got err=%v cmd=%v", ferr, alias)
}
} }
// wrapSheetsBackwardDeprecation must decorate each alias's Execute so that // wrapSheetsBackwardDeprecation must decorate each alias's Execute so that

View File

@@ -5,7 +5,6 @@ package backward
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -18,30 +17,20 @@ import (
// Drive media parent_type values for uploading an image into a spreadsheet. // Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a // Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" (being renamed to // synthetic token prefixed with "fake_office_" and the backend requires
// "local_office_") and the backend requires "office_sheet_file" instead. // "office_sheet_file" instead.
const ( const (
sheetImageParentType = "sheet_image" sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file" officeSheetFileParentType = "office_sheet_file"
fakeOfficeTokenPrefix = "fake_office_" fakeOfficeTokenPrefix = "fake_office_"
localOfficeTokenPrefix = "local_office_"
) )
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
// sheetMediaParentType returns the drive media parent_type to use when // sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken, mapping either the // uploading an image whose parent_node is spreadsheetToken, mapping the
// "fake_office_" or "local_office_" imported-spreadsheet token prefix to // "fake_office_" imported-spreadsheet token prefix to "office_sheet_file".
// "office_sheet_file".
func sheetMediaParentType(spreadsheetToken string) string { func sheetMediaParentType(spreadsheetToken string) string {
for _, prefix := range officeTokenPrefixes { if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
if strings.HasPrefix(spreadsheetToken, prefix) { return officeSheetFileParentType
return officeSheetFileParentType
}
} }
return sheetImageParentType return sheetImageParentType
} }
@@ -146,8 +135,7 @@ func validateSheetMediaUploadFile(runtime *common.RuntimeContext, filePath strin
stat, err := runtime.FileIO().Stat(filePath) stat, err := runtime.FileIO().Stat(filePath)
if err != nil { if err != nil {
wrapped := common.WrapInputStatErrorTyped(err, "file not found") wrapped := common.WrapInputStatErrorTyped(err, "file not found")
var v *errs.ValidationError if v, ok := wrapped.(*errs.ValidationError); ok {
if errors.As(wrapped, &v) {
return "", nil, v.WithParam("--file") return "", nil, v.WithParam("--file")
} }
return "", nil, wrapped return "", nil, wrapped

View File

@@ -102,8 +102,8 @@ func TestBatchOp_BodyMatchesStandalone(t *testing.T) {
{ {
shortcut: "+rows-resize", shortcut: "+rows-resize",
sc: RowsResize, sc: RowsResize,
args: []string{"--sheet-id", "sh1", "--range", "1", "--height", "30"}, args: []string{"--sheet-id", "sh1", "--range", "1", "--type", "pixel", "--size", "30"},
subInput: `{"sheet-id":"sh1","range":"1","height":30}`, subInput: `{"sheet-id":"sh1","range":"1","type":"pixel","size":30}`,
}, },
{ {
shortcut: "+cols-resize", shortcut: "+cols-resize",
@@ -409,12 +409,12 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
wantContains: "--count must be > 0", wantContains: "--count must be > 0",
}, },
{ {
name: "+rows-resize --height with --type standard", name: "+rows-resize --type pixel without --size",
shortcut: RowsResize, shortcut: RowsResize,
args: []string{"--sheet-id", "sh1", "--range", "1:2", "--height", "30", "--type", "standard"}, args: []string{"--sheet-id", "sh1", "--range", "1:2", "--type", "pixel"},
subShortcut: "+rows-resize", subShortcut: "+rows-resize",
subInput: `{"sheet-id":"sh1","range":"1:2","height":30,"type":"standard"}`, subInput: `{"sheet-id":"sh1","range":"1:2","type":"pixel"}`,
wantContains: "--height cannot be combined with --type standard", wantContains: "--type pixel requires --size",
}, },
{ {
name: "+sheet-delete missing sheet selector", name: "+sheet-delete missing sheet selector",
@@ -469,34 +469,6 @@ func TestBatchOp_ErrorEquivalence(t *testing.T) {
} }
} }
// TestBatchOp_RejectsResizeMapForm locks the nesting guard: the map form
// (--widths/--heights) expands into its own batch_update, and batch_update
// cannot nest, so a +batch-update sub-op carrying `widths`/`heights` must be
// rejected with a pointer to the standalone form — it is standalone-valid,
// so this case cannot live in the standalone-vs-batch equivalence table.
func TestBatchOp_RejectsResizeMapForm(t *testing.T) {
t.Parallel()
cases := []struct {
shortcut string
input string
}{
{"+cols-resize", `{"sheet-id":"sh1","widths":{"A":100}}`},
{"+rows-resize", `{"sheet-id":"sh1","heights":{"1":50}}`},
}
for _, tc := range cases {
t.Run(tc.shortcut, func(t *testing.T) {
t.Parallel()
var subInput map[string]interface{}
if err := json.Unmarshal([]byte(tc.input), &subInput); err != nil {
t.Fatalf("bad input JSON: %v", err)
}
rawOp := map[string]interface{}{"shortcut": tc.shortcut, "input": subInput}
_, err := translateBatchOp(rawOp, testToken, 0)
requireValidation(t, err, "not supported inside +batch-update")
})
}
}
// TestBatchOp_RejectsWrongScalarType locks the type-check that closes the // TestBatchOp_RejectsWrongScalarType locks the type-check that closes the
// silent-coercion gap: `operations` skips parse-time schema validation, and // silent-coercion gap: `operations` skips parse-time schema validation, and
// mapFlagView coerces a mismatched scalar to its zero value, so a sub-op field // mapFlagView coerces a mismatched scalar to its zero value, so a sub-op field
@@ -639,10 +611,10 @@ func TestBatchOp_RejectsBadSubOpInput(t *testing.T) {
"--position is required", "--position is required",
}, },
{ {
"+rows-resize missing both --height and --type", "+rows-resize missing --type",
"+rows-resize", "+rows-resize",
`{"sheet-id":"sh1","range":"1:1"}`, `{"sheet-id":"sh1","range":"1:1"}`,
"give --height <px> for a pixel size, or --type standard / auto", "--type is required",
}, },
{ {
"+range-copy missing --target-range", "+range-copy missing --target-range",
@@ -830,7 +802,7 @@ func TestBatchOp_DispatchCoversReportedBugs(t *testing.T) {
// bare single-element ranges. // bare single-element ranges.
body = parseDryRunBody(t, BatchUpdate, []string{ body = parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL, "--url", testURL,
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","height":40}}]`, "--operations", `[{"shortcut":"+rows-resize","input":{"sheet-id":"sh1","range":"23","type":"pixel","size":40}}]`,
"--yes", "--yes",
}) })
ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{}) ops = decodeToolInput(t, body, "batch_update")["operations"].([]interface{})

View File

@@ -4,7 +4,6 @@
package sheets package sheets
import ( import (
"sort"
"strings" "strings"
) )
@@ -119,19 +118,10 @@ var batchOpDispatch = map[string]batchOpMapping{
}}, }},
// ─── 行高列宽 (resize_range, 无 operation 字段) ───────────────── // ─── 行高列宽 (resize_range, 无 operation 字段) ─────────────────
// The map form (--heights/--widths) fans out into its own batch_update
// and cannot nest inside +batch-update; sub-ops must use the uniform
// single-range form (range + height/width or type).
"+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { "+rows-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
if err := rejectResizeMapInBatch(fv, "row"); err != nil {
return nil, err
}
return resizeInput(fv, token, sid, sname, "row") return resizeInput(fv, token, sid, sname, "row")
}}, }},
"+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) { "+cols-resize": {"resize_range", func(fv flagView, token, sid, sname string) (map[string]interface{}, error) {
if err := rejectResizeMapInBatch(fv, "column"); err != nil {
return nil, err
}
return resizeInput(fv, token, sid, sname, "column") return resizeInput(fv, token, sid, sname, "column")
}}, }},
@@ -207,54 +197,6 @@ var batchOpDispatch = map[string]batchOpMapping{
"+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)}, "+float-image-delete": {"manage_float_image_object", objDeleteTranslate(floatImageDeleteSpec)},
} }
// allowedBatchShortcuts lists every shortcut accepted inside +batch-update,
// sorted, for the not-allowed error hint.
func allowedBatchShortcuts() []string {
out := make([]string, 0, len(batchOpDispatch))
for sc := range batchOpDispatch {
out = append(out, sc)
}
sort.Strings(out)
return out
}
// subOpInputContract renders one shortcut's complete sub-op key vocabulary
// (wire-style underscore names) for the translator-failure hint: required
// flags are marked, the sheet selector pair collapses to a choose-one, and
// spreadsheet locators are omitted (reserved for the batch top level).
// Returns "" for shortcuts without a flag-defs entry.
func subOpInputContract(sc string) string {
defs, _ := loadFlagDefs()
spec, ok := defs[sc]
if !ok {
return ""
}
idFlag, nameFlag := sheetSelectorFlagsForSubOp(sc)
var keys []string
sheetSelector := ""
for _, df := range spec.Flags {
if df.Kind == "system" || df.Hidden {
continue
}
switch df.Name {
case "url", "spreadsheet-token":
continue // reserved: supplied by +batch-update top level
case idFlag, nameFlag:
sheetSelector = strings.ReplaceAll(idFlag, "-", "_") + "|" + strings.ReplaceAll(nameFlag, "-", "_") + " (choose one)"
continue
}
key := strings.ReplaceAll(df.Name, "-", "_")
if df.Required == "required" {
key += " (required)"
}
keys = append(keys, key)
}
if sheetSelector != "" {
keys = append([]string{sheetSelector}, keys...)
}
return strings.Join(keys, ", ")
}
// rejectLocalImageInBatch blocks the local-file --image source inside // rejectLocalImageInBatch blocks the local-file --image source inside
// +batch-update: a batch sub-op has no upload phase, so the file could not be // +batch-update: a batch sub-op has no upload phase, so the file could not be
// turned into a file_token. Callers must pass --image-token / --image-uri. // turned into a file_token. Callers must pass --image-token / --image-uri.
@@ -320,8 +262,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
} }
scRaw, present := op["shortcut"] scRaw, present := op["shortcut"]
if !present { if !present {
return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index). return nil, sheetsValidationForFlag("operations", "operations[%d]: 'shortcut' field is required", index)
WithHint(`each entry must look like {"shortcut":"+cells-set","input":{"sheet_name":"…","range":"A1:B2","cells":[[…]]}} — input uses the shortcut's own flag names`)
} }
sc, ok := scRaw.(string) sc, ok := scRaw.(string)
if !ok || sc == "" { if !ok || sc == "" {
@@ -329,15 +270,13 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
} }
mapping, ok := batchOpDispatch[sc] mapping, ok := batchOpDispatch[sc]
if !ok { if !ok {
// Inline the full allow-list: an agent that guessed a read op or a
// fan-out wrapper can pick the right shortcut immediately instead of
// spending a --print-schema round trip on the operations enum.
return nil, sheetsValidationForFlag( return nil, sheetsValidationForFlag(
"operations", "operations",
"operations[%d]: shortcut %q not allowed in +batch-update "+ "operations[%d]: shortcut %q not allowed in +batch-update "+
"(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded)", "(read ops / fan-out wrappers like +batch-update / +cells-batch-set-style / +cells-batch-clear / +dropdown-{update,delete} are excluded; "+
"run `lark-cli sheets +batch-update --print-schema --flag-name operations` to see the full enum)",
index, sc, index, sc,
).WithHint("allowed shortcuts: %s", strings.Join(allowedBatchShortcuts(), ", ")) )
} }
inputRaw, hasInput := op["input"] inputRaw, hasInput := op["input"]
var input map[string]interface{} var input map[string]interface{}
@@ -385,14 +324,7 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
sheetName := strings.TrimSpace(fv.Str(sheetNameFlag)) sheetName := strings.TrimSpace(fv.Str(sheetNameFlag))
body, err := mapping.translate(fv, token, sheetID, sheetName) body, err := mapping.translate(fv, token, sheetID, sheetName)
if err != nil { if err != nil {
// The inner error names one problem at a time (first missing flag); return nil, sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
// the hint lists the sub-op's complete key contract so an agent fixes
// every gap in a single retry instead of iterating flag by flag.
verr := sheetsValidationForFlag("operations", "operations[%d] (%s): %v", index, sc, err)
if contract := subOpInputContract(sc); contract != "" {
verr = verr.WithHint("%s input keys: %s", sc, contract)
}
return nil, verr
} }
return map[string]interface{}{ return map[string]interface{}{
"tool_name": mapping.mcpToolName, "tool_name": mapping.mcpToolName,
@@ -400,23 +332,11 @@ func translateBatchOp(raw interface{}, token string, index int) (map[string]inte
}, nil }, nil
} }
// maxBatchOperations caps how many sub-operations a single +batch-update may
// carry. Every translated op (with its own cells/properties payload) is held in
// the out slice at once before the whole batch is marshaled, so an unbounded
// operation count is the same unbounded-materialization hazard as the fan-out
// matrix, on the operations axis.
const maxBatchOperations = 100
// translateBatchOperations 翻译整个 ops 数组fail-fast遇错立即返回。 // translateBatchOperations 翻译整个 ops 数组fail-fast遇错立即返回。
func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) { func translateBatchOperations(rawOps []interface{}, token string) ([]interface{}, error) {
if len(rawOps) == 0 { if len(rawOps) == 0 {
return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array") return nil, sheetsValidationForFlag("operations", "--operations must be a non-empty JSON array")
} }
if len(rawOps) > maxBatchOperations {
batches := (len(rawOps) + maxBatchOperations - 1) / maxBatchOperations
return nil, sheetsValidationForFlag("operations", "--operations accepts at most %d entries; got %d", maxBatchOperations, len(rawOps)).
WithHint("split the operations into %d separate +batch-update calls of at most %d entries each", batches, maxBatchOperations)
}
out := make([]interface{}, 0, len(rawOps)) out := make([]interface{}, 0, len(rawOps))
for i, raw := range rawOps { for i, raw := range rawOps {
translated, err := translateBatchOp(raw, token, i) translated, err := translateBatchOp(raw, token, i)

View File

@@ -1,92 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"testing"
"github.com/larksuite/cli/internal/cmdutil"
"github.com/spf13/cobra"
)
// +csv-put lets a piped CSV satisfy an omitted --csv: agents routinely redirect
// a file into stdin but forget the `--csv -`. PostMount relaxes the required
// gate and installs a PreRunE that, when stdin is a non-interactive pipe,
// defaults an absent --csv to "-" so the standard stdin path reads it. On an
// interactive terminal the fallback stays off so the command never blocks.
func mountCsvPut(t *testing.T) *cobra.Command {
t.Helper()
f, _, _, _ := cmdutil.TestFactory(t, nil)
parent := &cobra.Command{Use: "sheets"}
CsvPut.Mount(parent, f)
cmd, _, err := parent.Find([]string{"+csv-put"})
if err != nil {
t.Fatalf("Find(+csv-put) error = %v", err)
}
return cmd
}
func csvRequiredAnnotationPresent(cmd *cobra.Command) bool {
fl := cmd.Flags().Lookup("csv")
if fl == nil {
return false
}
_, ok := fl.Annotations[cobra.BashCompOneRequiredFlag]
return ok
}
// withStdinIsPipe swaps the package-level pipe detector for the duration of a
// test so behavior does not depend on the real process stdin.
func withStdinIsPipe(t *testing.T, piped bool) {
t.Helper()
prev := csvPutStdinIsPipe
csvPutStdinIsPipe = func() bool { return piped }
t.Cleanup(func() { csvPutStdinIsPipe = prev })
}
func TestCsvPutPostMount_RelaxesCsvRequired(t *testing.T) {
cmd := mountCsvPut(t)
if csvRequiredAnnotationPresent(cmd) {
t.Error("--csv required annotation should be relaxed so csvPutInput reports the typed error")
}
if cmd.PreRunE == nil {
t.Fatal("PostMount should install a PreRunE for the stdin fallback")
}
}
func TestCsvPutPreRunE_PipedAbsentDefaultsToDash(t *testing.T) {
withStdinIsPipe(t, true)
cmd := mountCsvPut(t)
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("PreRunE error = %v", err)
}
if got, _ := cmd.Flags().GetString("csv"); got != "-" {
t.Errorf("csv = %q, want %q (piped + absent should default to '-')", got, "-")
}
}
func TestCsvPutPreRunE_InteractiveAbsentStaysEmpty(t *testing.T) {
withStdinIsPipe(t, false)
cmd := mountCsvPut(t)
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("PreRunE error = %v", err)
}
if got, _ := cmd.Flags().GetString("csv"); got != "" {
t.Errorf("csv = %q, want empty (interactive stdin must not be consumed / must not hang)", got)
}
}
func TestCsvPutPreRunE_ExplicitValueUnchanged(t *testing.T) {
withStdinIsPipe(t, true)
cmd := mountCsvPut(t)
if err := cmd.Flags().Set("csv", "x,y"); err != nil {
t.Fatalf("Set(csv) error = %v", err)
}
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("PreRunE error = %v", err)
}
if got, _ := cmd.Flags().GetString("csv"); got != "x,y" {
t.Errorf("csv = %q, want %q (explicit value must not be overridden)", got, "x,y")
}
}

View File

@@ -1,59 +1,4 @@
{ {
"+formula-verify": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "sheet-id",
"kind": "public",
"type": "string_slice",
"required": "optional",
"desc": "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
},
{
"name": "sheet-name",
"kind": "public",
"type": "string_slice",
"required": "optional",
"desc": "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."
},
{
"name": "range",
"kind": "own",
"type": "string_slice",
"required": "optional",
"desc": "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."
},
{
"name": "max-locations",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max locations / samples per error type; default 20.",
"default": "20"
},
{
"name": "exit-on-error",
"kind": "own",
"type": "bool",
"required": "optional",
"desc": "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."
}
]
},
"+workbook-info": { "+workbook-info": {
"risk": "read", "risk": "read",
"flags": [ "flags": [
@@ -80,32 +25,6 @@
} }
] ]
}, },
"+revision-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+sheet-create": { "+sheet-create": {
"risk": "write", "risk": "write",
"flags": [ "flags": [
@@ -154,18 +73,6 @@
"desc": "Initial column count (default 20, max 200)", "desc": "Initial column count (default 20, max 200)",
"default": "20" "default": "20"
}, },
{
"name": "type",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands",
"default": "sheet",
"enum": [
"sheet",
"bitable"
]
},
{ {
"name": "dry-run", "name": "dry-run",
"kind": "system", "kind": "system",
@@ -312,7 +219,7 @@
"kind": "own", "kind": "own",
"type": "int", "type": "int",
"required": "optional", "required": "optional",
"desc": "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", "desc": "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`",
"default": "-1" "default": "-1"
}, },
{ {
@@ -608,7 +515,7 @@
"kind": "own", "kind": "own",
"type": "string", "type": "string",
"required": "optional", "required": "optional",
"desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", "desc": "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes",
"input": [ "input": [
"file", "file",
"stdin" "stdin"
@@ -1162,7 +1069,7 @@
"kind": "own", "kind": "own",
"type": "int", "type": "int",
"required": "optional", "required": "optional",
"desc": "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", "desc": "Group nesting level to ungroup; default 1 (outermost)",
"default": "1" "default": "1"
}, },
{ {
@@ -1804,13 +1711,6 @@
"required": "optional", "required": "optional",
"desc": "Font color (hex, e.g. `#000000`)" "desc": "Font color (hex, e.g. `#000000`)"
}, },
{
"name": "font-family",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
},
{ {
"name": "font-size", "name": "font-size",
"kind": "own", "kind": "own",
@@ -2394,43 +2294,32 @@
"required": "xor", "required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)" "desc": "Sheet name (XOR with `--sheet-id`)"
}, },
{
"name": "height",
"kind": "own",
"type": "int",
"required": "xor",
"desc": "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`",
"default": "0"
},
{
"name": "heights",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`",
"input": [
"file",
"stdin"
]
},
{ {
"name": "type", "name": "type",
"kind": "own", "kind": "own",
"type": "string", "type": "string",
"required": "xor", "required": "required",
"desc": "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`", "desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)",
"enum": [ "enum": [
"pixel", "pixel",
"standard", "standard",
"auto" "auto"
] ]
}, },
{
"name": "size",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise",
"default": "0"
},
{ {
"name": "range", "name": "range",
"kind": "own", "kind": "own",
"type": "string", "type": "string",
"required": "xor", "required": "required",
"desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)" "desc": "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"
}, },
{ {
"name": "dry-run", "name": "dry-run",
@@ -2472,42 +2361,31 @@
"required": "xor", "required": "xor",
"desc": "Sheet name (XOR with `--sheet-id`)" "desc": "Sheet name (XOR with `--sheet-id`)"
}, },
{
"name": "width",
"kind": "own",
"type": "int",
"required": "xor",
"desc": "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`",
"default": "0"
},
{
"name": "widths",
"kind": "own",
"type": "string",
"required": "xor",
"desc": "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`",
"input": [
"file",
"stdin"
]
},
{ {
"name": "type", "name": "type",
"kind": "own", "kind": "own",
"type": "string", "type": "string",
"required": "xor", "required": "required",
"desc": "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`", "desc": "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)",
"enum": [ "enum": [
"pixel", "pixel",
"standard" "standard"
] ]
}, },
{
"name": "size",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise",
"default": "0"
},
{ {
"name": "range", "name": "range",
"kind": "own", "kind": "own",
"type": "string", "type": "string",
"required": "xor", "required": "required",
"desc": "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)" "desc": "Column closed range to resize; column letters like `A:E` or `C` (single column)"
}, },
{ {
"name": "dry-run", "name": "dry-run",
@@ -2861,7 +2739,7 @@
"kind": "own", "kind": "own",
"type": "string", "type": "string",
"required": "required", "required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", "desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range",
"input": [ "input": [
"file", "file",
"stdin" "stdin"
@@ -2881,13 +2759,6 @@
"required": "optional", "required": "optional",
"desc": "Font color (hex, e.g. `#000000`)" "desc": "Font color (hex, e.g. `#000000`)"
}, },
{
"name": "font-family",
"kind": "own",
"type": "string",
"required": "optional",
"desc": "Font family name (e.g. `Arial`, `Microsoft YaHei`)"
},
{ {
"name": "font-size", "name": "font-size",
"kind": "own", "kind": "own",
@@ -3014,7 +2885,7 @@
"kind": "own", "kind": "own",
"type": "string", "type": "string",
"required": "required", "required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", "desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
"input": [ "input": [
"file", "file",
"stdin" "stdin"
@@ -3094,7 +2965,7 @@
"kind": "own", "kind": "own",
"type": "string", "type": "string",
"required": "required", "required": "required",
"desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", "desc": "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id",
"input": [ "input": [
"file", "file",
"stdin" "stdin"
@@ -3138,7 +3009,7 @@
"kind": "own", "kind": "own",
"type": "string", "type": "string",
"required": "required", "required": "required",
"desc": "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", "desc": "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range",
"input": [ "input": [
"file", "file",
"stdin" "stdin"
@@ -3256,7 +3127,7 @@
"kind": "own", "kind": "own",
"type": "string", "type": "string",
"required": "required", "required": "required",
"desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", "desc": "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.",
"input": [ "input": [
"file", "file",
"stdin" "stdin"
@@ -4195,7 +4066,7 @@
"kind": "own", "kind": "own",
"type": "string", "type": "string",
"required": "optional", "required": "optional",
"desc": "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`" "desc": "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"
}, },
{ {
"name": "dry-run", "name": "dry-run",
@@ -4876,138 +4747,5 @@
"desc": "" "desc": ""
} }
] ]
},
"+history-list": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "end-version",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+history-revert": {
"risk": "write",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "history-version-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "History version to revert to (from +history-list)."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+history-revert-status": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet locator"
},
{
"name": "transaction-id",
"kind": "own",
"type": "string",
"required": "required",
"desc": "Async revert transaction id (from +history-revert)."
},
{
"name": "dry-run",
"kind": "system",
"type": "bool",
"required": "optional",
"desc": ""
}
]
},
"+changeset-get": {
"risk": "read",
"flags": [
{
"name": "url",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet URL (XOR with `--spreadsheet-token`)"
},
{
"name": "spreadsheet-token",
"kind": "public",
"type": "string",
"required": "xor",
"desc": "Spreadsheet token (XOR with `--url`)"
},
{
"name": "start-revision",
"kind": "own",
"type": "int",
"required": "required",
"desc": "Start version (CS revision); the before baseline for review (must be >= 1)"
},
{
"name": "end-revision",
"kind": "own",
"type": "int",
"required": "optional",
"desc": "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20",
"default": "-1"
}
]
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -27,7 +27,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{ Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:Z1000\",\"Sheet2!A2:Z1000\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}}, {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:Z1000\",\"'Sheet2'!A2:Z1000\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same scope is cleared from every range", Input: []string{"file", "stdin"}},
{Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}}, {Name: "scope", Kind: "own", Type: "string", Required: "optional", Desc: "Clear scope: `content` (default, values only) / `formats` (formats only) / `all` (values and formats)", Default: "content", Enum: []string{"content", "formats", "all"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"}, {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm destructive write (exit code 10 without this flag); batch clear is irreversible"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
@@ -38,10 +38,9 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{ Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A1:B2\",\"Sheet2!D1:D10\"]`, prefix written bare without quotes); each prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}}, {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A1:B2\",\"'Sheet2'!D1:D10\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id; ranges may target different sheets; the same style is applied to every range", Input: []string{"file", "stdin"}},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"}, {Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"}, {Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"}, {Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}}, {Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}}, {Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
@@ -166,7 +165,6 @@ var flagDefs = map[string]commandDef{
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"}, {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Target range (A1 notation, e.g. `A1:B2`)"},
{Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"}, {Name: "background-color", Kind: "own", Type: "string", Required: "optional", Desc: "Background color (hex, e.g. `#ffffff`)"},
{Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"}, {Name: "font-color", Kind: "own", Type: "string", Required: "optional", Desc: "Font color (hex, e.g. `#000000`)"},
{Name: "font-family", Kind: "own", Type: "string", Required: "optional", Desc: "Font family name (e.g. `Arial`, `Microsoft YaHei`)"},
{Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"}, {Name: "font-size", Kind: "own", Type: "float64", Required: "optional", Desc: "Font size in px (e.g. 10, 12, 14)"},
{Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}}, {Name: "font-style", Kind: "own", Type: "string", Required: "optional", Desc: "Font style", Enum: []string{"normal", "italic"}},
{Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}}, {Name: "font-weight", Kind: "own", Type: "string", Required: "optional", Desc: "Font weight", Enum: []string{"normal", "bold"}},
@@ -190,15 +188,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
}, },
}, },
"+changeset-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "start-revision", Kind: "own", Type: "int", Required: "required", Desc: "Start version (CS revision); the before baseline for review (must be >= 1)"},
{Name: "end-revision", Kind: "own", Type: "int", Required: "optional", Desc: "End version (CS revision); defaults to the latest revision. Gap (end-start+1) must be <= 20", Default: "-1"},
},
},
"+chart-create": { "+chart-create": {
Risk: "write", Risk: "write",
Flags: []flagDef{ Flags: []flagDef{
@@ -206,7 +195,7 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`); must include at least one of `snapshot.data.dim1.serie.index` or `dim2.series[].index`, otherwise the server rejects it. Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}}, {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Full chart config JSON. Top-level keys: `position` / `offset` / `size` / `snapshot` (no top-level `data`, no extra nested `properties`); chart data config lives under `snapshot.data` (`refs` / `headerMode` / `dim1` / `dim2`). Deeply nested — run `--print-schema --flag-name properties` for the full structure.", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional", Desc: "Print the request template; no side effects"},
}, },
}, },
@@ -252,10 +241,9 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "width", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform column width in pixels (e.g. 80 / 120 / 200; NOT Excel character units), used with `--range`. Passing --width implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-column widths use `--widths`", Default: "0"}, {Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default column width)", Enum: []string{"pixel", "standard"}},
{Name: "widths", Kind: "own", Type: "string", Required: "xor", Desc: "Per-column width map — set different widths for many columns in one atomic call. Keys: single column (`\"A\"`) or closed range (`\"C:E\"`); values: pixel width (e.g. 80 / 120 / 200) or `\"standard\"` (reset to default). Units are pixels, NOT Excel character units (px ≈ chars × 8 + 16). Mutually exclusive with `--range` / `--width` / `--type`", Input: []string{"file", "stdin"}}, {Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Column width in pixels (e.g. 80 / 120 / 200); required when `--type pixel`, ignored otherwise", Default: "0"},
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--width`) / `standard` (reset to default column width). Passing --width alone is the common form; `--type standard` cannot be combined with `--width`", Enum: []string{"pixel", "standard"}}, {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column)"},
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Column closed range to resize; column letters like `A:E` or `C` (single column). Required for the uniform form (with `--width` or `--type`); omit with the map form (`--widths`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
}, },
}, },
@@ -417,7 +405,7 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (1 = outermost, larger = deeper)", Default: "1"}, {Name: "depth", Kind: "own", Type: "int", Required: "optional", Desc: "Group nesting level to ungroup; default 1 (outermost)", Default: "1"},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"}, {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row/column closed range to ungroup; rows use 1-based numbers like `3:7`, columns use letters like `C:F`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
}, },
@@ -438,7 +426,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{ Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"Sheet1!E2:E6\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}}, {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (up to 100 items, e.g. `[\"'Sheet1'!E2:E6\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"}, {Name: "yes", Kind: "system", Type: "bool", Required: "required", Desc: "Confirm high-risk write (exit code 10 without this flag)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
}, },
@@ -475,7 +463,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{ Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"}, {Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"Sheet1!A2:A100\",\"Sheet1!C2:C100\"]`, prefix written bare without quotes); each item must include a sheet prefix; the prefix must exactly match the sheet display name (case-sensitive), not the sheet reference_id", Input: []string{"file", "stdin"}}, {Name: "ranges", Kind: "own", Type: "string", Required: "required", Desc: "Target ranges as a JSON array (e.g. `[\"'Sheet1'!A2:A100\",\"'Sheet1'!C2:C100\"]`); each item must include a sheet prefix; the prefix must be the sheet display name (e.g. `Sheet1`), not the sheet reference_id", Input: []string{"file", "stdin"}},
{Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}}, {Name: "options", Kind: "own", Type: "string", Required: "xor", Desc: "Options as a JSON array, e.g. `[\"opt1\",\"opt2\"]`. Server enforces no item-count cap and no per-item length cap; values containing commas are accepted (they are escape-encoded on the wire). For very large lists prefer `--source-range`.", Input: []string{"file", "stdin"}},
{Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}}, {Name: "colors", Kind: "own", Type: "string", Required: "optional", Desc: "Per-option pill colors, RGB hex array (e.g. `[\"#1FB6C1\",\"#F006C2\"]`). Length may be shorter than the source (`--options` items / `--source-range` cells) — extras cycle through a 10-color palette — but never longer (CLI Validate rejects: `--colors length (N) must not exceed dropdown source size (M)`). **Applies on its own**; ignored when `--highlight=false`.", Input: []string{"file", "stdin"}},
{Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"}, {Name: "multiple", Kind: "own", Type: "bool", Required: "optional", Desc: "Enable multi-select"},
@@ -538,7 +526,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}}, {Name: "properties", Kind: "own", Type: "string", Required: "required", Desc: "Filter-view rule JSON: `rules?` (per-column rule array), `filtered_columns?`. `range` and `view_name` are separate flags", Input: []string{"file", "stdin"}},
{Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"}, {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Range the filter view applies to (A1 notation, e.g. `A1:F1000`); takes precedence over the same-named field inside `--properties`; required on create and must cover the header row"},
{Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted; takes precedence over the same-named field inside `--properties`"}, {Name: "view-name", Kind: "own", Type: "string", Required: "optional", Desc: "Filter-view name; auto-assigned by the server when omitted on create, kept unchanged when omitted on update; takes precedence over the same-named field inside `--properties`"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
}, },
}, },
@@ -644,45 +632,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
}, },
}, },
"+formula-verify": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet URL (XOR with `--spreadsheet-token`)"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet reference_id(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
{Name: "sheet-name", Kind: "public", Type: "string_slice", Required: "optional", Desc: "Sheet name(s); repeat or comma-separate to scan multiple sheets. Omit to scan all visible sheets."},
{Name: "range", Kind: "own", Type: "string_slice", Required: "optional", Desc: "Optional A1 ranges (e.g. `A1:Z200`); repeat or comma-separate for multiple ranges. Omit to scan each sheet's current_region."},
{Name: "max-locations", Kind: "own", Type: "int", Required: "optional", Desc: "Max locations / samples per error type; default 20.", Default: "20"},
{Name: "exit-on-error", Kind: "own", Type: "bool", Required: "optional", Desc: "When status=errors_found, exit non-zero. Useful for CI gate after batch formula writes."},
},
},
"+history-list": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "end-version", Kind: "own", Type: "int", Required: "optional", Desc: "Max version to query (descending pagination). Omit on the first call; pass next_end_version from the previous response."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+history-revert": {
Risk: "write",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "history-version-id", Kind: "own", Type: "string", Required: "required", Desc: "History version to revert to (from +history-list)."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+history-revert-status": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "transaction-id", Kind: "own", Type: "string", Required: "required", Desc: "Async revert transaction id (from +history-revert)."},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+pivot-create": { "+pivot-create": {
Risk: "write", Risk: "write",
Flags: []flagDef{ Flags: []flagDef{
@@ -785,14 +734,6 @@ var flagDefs = map[string]commandDef{
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
}, },
}, },
"+revision-get": {
Risk: "read",
Flags: []flagDef{
{Name: "url", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet locator"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
},
},
"+rows-resize": { "+rows-resize": {
Risk: "write", Risk: "write",
Flags: []flagDef{ Flags: []flagDef{
@@ -800,10 +741,9 @@ var flagDefs = map[string]commandDef{
{Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"}, {Name: "spreadsheet-token", Kind: "public", Type: "string", Required: "xor", Desc: "Spreadsheet token (XOR with `--url`)"},
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "height", Kind: "own", Type: "int", Required: "xor", Desc: "Uniform row height in pixels (e.g. 30 / 40 / 60; NOT points), used with `--range`. Passing --height implies pixel mode; --type may be omitted (or set to `pixel` — equivalent). For per-row heights use `--heights`", Default: "0"}, {Name: "type", Kind: "own", Type: "string", Required: "required", Desc: "Sizing mode: `pixel` (explicit px value, requires `--size`) / `standard` (reset to default row height) / `auto` (fit content)", Enum: []string{"pixel", "standard", "auto"}},
{Name: "heights", Kind: "own", Type: "string", Required: "xor", Desc: "Per-row height map — set different heights for many rows in one atomic call. Keys: single row (`\"1\"`) or closed range (`\"2:20\"`); values: pixel height (e.g. 30 / 50), `\"auto\"` (fit content) or `\"standard\"` (reset to default). Units are pixels, NOT points. Mutually exclusive with `--range` / `--height` / `--type`", Input: []string{"file", "stdin"}}, {Name: "size", Kind: "own", Type: "int", Required: "optional", Desc: "Row height in pixels (e.g. 30 / 40 / 60); required when `--type pixel`, ignored otherwise", Default: "0"},
{Name: "type", Kind: "own", Type: "string", Required: "xor", Desc: "Sizing mode: `pixel` (requires `--height`) / `standard` (reset to default row height) / `auto` (fit content). Passing --height alone is the common form; `--type standard` / `--type auto` cannot be combined with `--height`", Enum: []string{"pixel", "standard", "auto"}}, {Name: "range", Kind: "own", Type: "string", Required: "required", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row)"},
{Name: "range", Kind: "own", Type: "string", Required: "xor", Desc: "Row closed range to resize; 1-based row numbers like `2:10` or `5` (single row). Required for the uniform form (with `--height` or `--type`); omit with the map form (`--heights`)"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
}, },
}, },
@@ -828,7 +768,6 @@ var flagDefs = map[string]commandDef{
{Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"}, {Name: "index", Kind: "own", Type: "int", Required: "optional", Desc: "Insert position (0-based); appended to the end when omitted", Default: "-1"},
{Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"}, {Name: "row-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial row count (default 200, max 50000)", Default: "200"},
{Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"}, {Name: "col-count", Kind: "own", Type: "int", Required: "optional", Desc: "Initial column count (default 20, max 200)", Default: "20"},
{Name: "type", Kind: "own", Type: "string", Required: "optional", Desc: "New sub-sheet type: sheet (spreadsheet) | bitable; default sheet. bitable creates an empty table only — edit its content via lark-base commands", Default: "sheet", Enum: []string{"sheet", "bitable"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
}, },
}, },
@@ -883,7 +822,7 @@ var flagDefs = map[string]commandDef{
{Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"}, {Name: "sheet-id", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet reference_id (XOR with `--sheet-name`)"},
{Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"}, {Name: "sheet-name", Kind: "public", Type: "string", Required: "xor", Desc: "Sheet name (XOR with `--sheet-id`)"},
{Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"}, {Name: "index", Kind: "own", Type: "int", Required: "required", Desc: "Target position (0-based)"},
{Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional for standalone calls — if omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`. Inside `+batch-update` it must be passed explicitly, since batch cannot issue a structure query mid-run to derive it", Default: "-1"}, {Name: "source-index", Kind: "own", Type: "int", Required: "optional", Desc: "Source position (0-based); optional. If omitted, the CLI runtime derives it from the current workbook index of `--sheet-id` / `--sheet-name`", Default: "-1"},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},
}, },
}, },
@@ -1002,7 +941,7 @@ var flagDefs = map[string]commandDef{
Flags: []flagDef{ Flags: []flagDef{
{Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"}, {Name: "title", Kind: "own", Type: "string", Required: "required", Desc: "Spreadsheet title"},
{Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"}, {Name: "folder-token", Kind: "own", Type: "string", Required: "optional", Desc: "Target folder token; placed at the drive root when omitted"},
{Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected (dates / numbers land as text — use --sheets to preserve types), through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}}, {Name: "values", Kind: "own", Type: "string", Required: "optional", Desc: "Untyped initial data as one 2D JSON array (`[[\"alice\",95]]`); values are written as-is with their type auto-detected, through the same batched set_cell_range path as --sheets — pair with --styles for number formats, colors, merges, and row/col sizes", Input: []string{"file", "stdin"}},
{Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}}, {Name: "sheets", Kind: "own", Type: "string", Required: "optional", Desc: "Typed table payload as JSON (same shape as `+table-put`): top-level `{\"sheets\":[...]}`, with each array item a sub-sheet `{name, start_cell?, mode?, header?, allow_overwrite?, columns:[\"colA\",\"colB\",...], data:[[...]], dtypes?:{colA:pandasDtype, ...}, formats?:{colA:numberFormat, ...}}` — `name` and the outer `sheets` envelope are both required. Agents typically use `df_to_sheet(df, name)` from `scripts/sheets_df.py` to pack each DataFrame into one item, then wrap the list in `{\"sheets\":[...]}`. Mutually exclusive with --values. Creates the workbook, then writes typed type-faithful data (dates land as real dates, numbers keep precision).", Input: []string{"file", "stdin"}},
{Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}}, {Name: "styles", Kind: "own", Type: "string", Required: "optional", Desc: "Initial visual operations as JSON: top-level `{styles:[...]}`. Each item corresponds to one target sheet and must include `name`, plus at least one of `cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`. `cell_styles` entries use +cells-set-style fields with a cell range; row/col sizes use dimension ranges plus type/size; merges use cell ranges plus optional merge_type. With --sheets, styles array length/order/name must match --sheets.sheets. With --values, pass exactly one styles item for the initial sheet (its name is ignored).", Input: []string{"file", "stdin"}},
{Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"}, {Name: "dry-run", Kind: "system", Type: "bool", Required: "optional"},

View File

@@ -1,238 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"fmt"
"slices"
"sort"
"strings"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/internal/suggest"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// ─── sheets flag ergonomics ─────────────────────────────────────────────
//
// Eval traces show two recovery loops that burn agent round-trips on the
// sheets domain specifically: hallucinated flag names (--cols for --range,
// --file for --csv) whose unknown-flag error only points at --help, and
// enum values imported from CSS / Excel vocabulary ("center" for the
// vertical alignment Lark spells "middle"). Both fixes are wired through
// the existing PostMount hook — composed onto any prior PostMount in
// Shortcuts(), same pattern as withTokenAlias — so the common framework
// needs no change at all and no other domain's behavior shifts.
// withFlagErgonomics wraps an optional PostMount so that, after it runs,
// the command gets the sheets-specific unknown-flag error (valid flags
// inlined) and enum-value normalization (canonical vocabulary auto-applied,
// typos suggested).
func withFlagErgonomics(prev func(cmd *cobra.Command)) func(cmd *cobra.Command) {
return func(cmd *cobra.Command) {
if prev != nil {
prev(cmd)
}
cmd.SetFlagErrorFunc(sheetsFlagErrorFunc)
chainEnumNormalization(cmd)
}
}
// sheetsFlagErrorFunc overrides the root FlagErrorFunc for sheets commands.
// It keeps the root behavior (typed error, did-you-mean suggestions, the
// offending flag on params) and additionally inlines the full valid-flag
// set: hallucinated sheets flags are usually semantic guesses (--cols for
// --range) that edit distance can't rank, and a --help round trip costs an
// agent a full extra call. One line here lets it re-issue the command
// immediately.
func sheetsFlagErrorFunc(c *cobra.Command, ferr error) error {
name, isUnknown := unknownFlagFromParseError(ferr)
if !isUnknown {
return common.ValidationErrorf("%s", ferr.Error()).
WithHint("run `%s --help` for valid flags", c.CommandPath())
}
valid := visibleFlagNames(c)
suggestions := suggest.Closest(name, valid, 3)
for i := range suggestions {
suggestions[i] = "--" + suggestions[i]
}
hint := fmt.Sprintf("run `%s --help` to see valid flags", c.CommandPath())
if list := inlineFlagList(valid); list != "" {
hint = "valid flags: " + list
if len(suggestions) > 0 {
hint = fmt.Sprintf("did you mean %s? valid flags: %s",
strings.Join(suggestions, ", "), list)
}
}
return errs.NewValidationError(errs.SubtypeInvalidArgument,
"unknown flag %q for %q", "--"+name, c.CommandPath()).
WithParams(errs.InvalidParam{Name: "--" + name, Reason: "unknown flag", Suggestions: suggestions}).
WithHint("%s", hint)
}
// unknownFlagFromParseError extracts the offending long-flag name from
// cobra's flag-parse error text ("unknown flag: --query" → "query").
// Returns ok=false for anything else (missing argument, invalid value,
// unknown shorthand) so those stay structured but generic. Mirrors the
// root-level parser in cmd; the prefix contract is cobra's English wording.
func unknownFlagFromParseError(err error) (string, bool) {
const p = "unknown flag: --"
msg := err.Error()
i := strings.Index(msg, p)
if i < 0 {
return "", false
}
rest := msg[i+len(p):]
if j := strings.IndexAny(rest, " \t"); j >= 0 {
rest = rest[:j]
}
return rest, true
}
// visibleFlagNames lists the non-hidden flag names registered on c, sorted.
func visibleFlagNames(c *cobra.Command) []string {
var names []string
c.Flags().VisitAll(func(f *pflag.Flag) {
if !f.Hidden {
names = append(names, f.Name)
}
})
sort.Strings(names)
return names
}
// inlineFlagListLimit caps how many flag names ride inline on an
// unknown-flag hint. Sheets shortcuts stay well under it.
const inlineFlagListLimit = 25
// inlineFlagList renders valid flag names as one comma-separated line for
// the unknown-flag hint, truncating past inlineFlagListLimit. Empty when
// there is nothing to list.
func inlineFlagList(names []string) string {
if len(names) == 0 {
return ""
}
shown := names
var suffix string
if len(names) > inlineFlagListLimit {
shown = names[:inlineFlagListLimit]
suffix = fmt.Sprintf(", … (%d more; see --help)", len(names)-inlineFlagListLimit)
}
parts := make([]string, len(shown))
for i, n := range shown {
parts[i] = "--" + n
}
return strings.Join(parts, ", ") + suffix
}
// ─── enum vocabulary normalization ──────────────────────────────────────
// enumAliases maps habitual values agents import from CSS / Excel / Google
// Sheets onto the value the Lark API actually uses, keyed by the wrong
// value. Applied only when the alias target is in the enum (and the wrong
// value is not), so e.g. "center" still stands for horizontal alignment
// (where it is valid) and only maps to "middle" for vertical alignment.
var enumAliases = map[string]string{
"center": "middle", // CSS vertical-align: center → Lark "middle"
"centre": "center",
"middle": "center", // CSS-style middle → Lark horizontal "center"
}
// canonicalEnumValue returns the enum entry an off-vocabulary value
// unambiguously means — exact case-insensitive match first, then the
// cross-vocabulary alias table. Unlike an edit-distance guess, the result
// is safe to apply on the caller's behalf. Returns "" when the value has
// no unambiguous canonical form in this enum.
func canonicalEnumValue(val string, enum []string) string {
lower := strings.ToLower(val)
for _, allowed := range enum {
if strings.ToLower(allowed) == lower {
return allowed
}
}
if target, ok := enumAliases[lower]; ok {
if slices.Contains(enum, target) {
return target
}
}
return ""
}
// closestEnumValue picks the best "did you mean" candidate for an invalid
// enum value: the unambiguous canonical form first, then edit distance.
// For prose suggestions only — an edit-distance match must never be
// auto-applied. Returns "" when nothing is close.
func closestEnumValue(val string, enum []string) string {
if canon := canonicalEnumValue(val, enum); canon != "" {
return canon
}
if match := suggest.Closest(val, enum, 1); len(match) > 0 {
return match[0]
}
return ""
}
// chainEnumNormalization installs a PreRunE stage (composed onto any
// framework-set PreRunE, which runs first so OnInvoke side effects and the
// --print-schema required-flag relaxation keep their contracts) that
// normalizes the command's flat enum flags before the common runner
// validates them:
//
// - an unambiguous vocabulary mismatch (casing, or a known alias like CSS
// "center" for Lark's vertical "middle") IS the value the caller meant —
// rewrite it in place and proceed instead of failing the call just to
// have the agent retype the canonical spelling;
// - anything else fails here with the allowed list plus a "did you mean"
// hint for edit-distance typos — a guess is never auto-applied.
//
// No-op for commands whose flag defs declare no enums.
func chainEnumNormalization(cmd *cobra.Command) {
defs, _ := loadFlagDefs()
spec, ok := defs[cmd.Name()]
if !ok {
return
}
var enumFlags []flagDef
for _, df := range spec.Flags {
if df.Kind != "system" && len(df.Enum) > 0 && df.Type == "string" {
enumFlags = append(enumFlags, df)
}
}
if len(enumFlags) == 0 {
return
}
prev := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if prev != nil {
if err := prev(c, args); err != nil {
return err
}
}
// --print-schema is pure local introspection; the runner never enum-
// validates that path, so don't start here.
if want, err := c.Flags().GetBool("print-schema"); err == nil && want {
return nil
}
for _, df := range enumFlags {
val, err := c.Flags().GetString(df.Name)
if err != nil || val == "" || slices.Contains(df.Enum, val) {
continue
}
if canon := canonicalEnumValue(val, df.Enum); canon != "" {
c.Flags().Set(df.Name, canon)
continue
}
verr := common.ValidationErrorf("invalid value %q for --%s, allowed: %s",
val, df.Name, strings.Join(df.Enum, ", ")).
WithParam("--" + df.Name)
if match := suggest.Closest(val, df.Enum, 1); len(match) > 0 {
verr = verr.WithHint("did you mean %q?", match[0])
}
return verr
}
return nil
}
}

View File

@@ -1,296 +0,0 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package sheets
import (
"errors"
"fmt"
"strings"
"testing"
"github.com/larksuite/cli/errs"
"github.com/larksuite/cli/shortcuts/common"
"github.com/spf13/cobra"
)
func TestUnknownFlagFromParseError(t *testing.T) {
t.Parallel()
cases := []struct {
in string
name string
ok bool
}{
{"unknown flag: --cols", "cols", true},
{"unknown flag: --with-styles", "with-styles", true},
{"unknown shorthand flag: 'z' in -z", "", false},
{"flag needs an argument: --find", "", false},
{`invalid argument "x" for "--count"`, "", false},
}
for _, c := range cases {
name, ok := unknownFlagFromParseError(errors.New(c.in))
if name != c.name || ok != c.ok {
t.Errorf("unknownFlagFromParseError(%q) = (%q,%v), want (%q,%v)", c.in, name, ok, c.name, c.ok)
}
}
}
// TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags pins the sheets
// override of the root unknown-flag error: --cols is a semantic guess for
// --range that edit distance can't rank, so the hint must inline the full
// valid-flag list instead of deferring to a --help round trip.
func TestSheetsFlagErrorFunc_SemanticGuessListsValidFlags(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().Int("width", 0, "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --cols"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Subtype != errs.SubtypeInvalidArgument {
t.Errorf("subtype = %q, want invalid_argument", verr.Subtype)
}
if len(verr.Params) != 1 || verr.Params[0].Name != "--cols" {
t.Errorf("Params = %v, want one entry named --cols", verr.Params)
}
if strings.Contains(verr.Hint, "--help") {
t.Errorf("hint should not defer to --help when flags fit inline, got %q", verr.Hint)
}
for _, want := range []string{"--range", "--width"} {
if !strings.Contains(verr.Hint, want) {
t.Errorf("hint should inline valid flag %s, got %q", want, verr.Hint)
}
}
}
// TestSheetsFlagErrorFunc_TypoKeepsSuggestion pins that the root behavior
// (did-you-mean suggestion, machine-readable Suggestions) is preserved by
// the sheets override, with the valid-flag list appended.
func TestSheetsFlagErrorFunc_TypoKeepsSuggestion(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
c.Flags().String("range", "", "")
c.Flags().Bool("dry-run", false, "")
err := sheetsFlagErrorFunc(c, errors.New("unknown flag: --rang"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
found := false
for _, s := range verr.Params[0].Suggestions {
if s == "--range" {
found = true
}
}
if !found {
t.Errorf("Suggestions should include --range, got %v", verr.Params[0].Suggestions)
}
for _, want := range []string{"did you mean", "--range", "--dry-run"} {
if !strings.Contains(verr.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, verr.Hint)
}
}
}
func TestSheetsFlagErrorFunc_OtherErrorStaysGeneric(t *testing.T) {
t.Parallel()
c := &cobra.Command{Use: "demo"}
err := sheetsFlagErrorFunc(c, errors.New("flag needs an argument: --find"))
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("expected *errs.ValidationError, got %T", err)
}
if verr.Param != "" || len(verr.Params) != 0 {
t.Errorf("Param=%q Params=%v, want both empty for generic flag error", verr.Param, verr.Params)
}
if strings.Contains(verr.Hint, "did you mean") {
t.Errorf("generic flag error must not produce a did-you-mean hint, got %q", verr.Hint)
}
}
func TestInlineFlagList_TruncatesPastLimit(t *testing.T) {
t.Parallel()
if got := inlineFlagList(nil); got != "" {
t.Errorf("inlineFlagList(nil) = %q, want empty", got)
}
names := make([]string, inlineFlagListLimit+5)
for i := range names {
names[i] = fmt.Sprintf("flag-%02d", i)
}
got := inlineFlagList(names)
if !strings.Contains(got, "5 more") || !strings.Contains(got, "--help") {
t.Errorf("truncated list should count the overflow and defer to --help, got %q", got)
}
if strings.Contains(got, names[inlineFlagListLimit]) {
t.Errorf("list should stop at the limit, got %q", got)
}
}
func TestCanonicalEnumValue(t *testing.T) {
t.Parallel()
cases := []struct {
val string
enum []string
want string
}{
{"SUM", []string{"sum", "count"}, "sum"}, // casing
{"center", []string{"top", "middle", "bottom"}, "middle"}, // alias: CSS vertical center
{"middle", []string{"left", "center", "right"}, "center"}, // alias: horizontal middle
{"overwite", []string{"append", "overwrite"}, ""}, // typo is NOT canonical
{"delete", []string{"append", "overwrite"}, ""}, // nothing close
}
for _, c := range cases {
if got := canonicalEnumValue(c.val, c.enum); got != c.want {
t.Errorf("canonicalEnumValue(%q, %v) = %q, want %q", c.val, c.enum, got, c.want)
}
}
}
func TestClosestEnumValue(t *testing.T) {
t.Parallel()
cases := []struct {
val string
enum []string
want string
}{
{"SUM", []string{"sum", "count"}, "sum"}, // casing
{"center", []string{"top", "middle", "bottom"}, "middle"}, // alias
{"overwite", []string{"append", "overwrite"}, "overwrite"}, // edit distance
{"delete", []string{"append", "overwrite"}, ""}, // nothing close
}
for _, c := range cases {
if got := closestEnumValue(c.val, c.enum); got != c.want {
t.Errorf("closestEnumValue(%q, %v) = %q, want %q", c.val, c.enum, got, c.want)
}
}
}
// TestChainEnumNormalization_UnitContract pins the PreRunE stage in
// isolation: canonical vocabulary is auto-applied, typos error with a
// suggestion (never applied), the framework PreRunE keeps running first,
// and --print-schema skips enum gating entirely.
func TestChainEnumNormalization_UnitContract(t *testing.T) {
t.Parallel()
newCmd := func() (*cobra.Command, *bool) {
cmd := &cobra.Command{Use: "+cells-set-style"}
cmd.Flags().String("vertical-alignment", "", "")
cmd.Flags().Bool("print-schema", false, "")
prevCalled := false
cmd.PreRunE = func(*cobra.Command, []string) error {
prevCalled = true
return nil
}
chainEnumNormalization(cmd)
return cmd, &prevCalled
}
// Alias auto-applied, framework PreRunE preserved.
cmd, prevCalled := newCmd()
cmd.Flags().Set("vertical-alignment", "center")
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Fatalf("center should normalize and pass, got: %v", err)
}
if got, _ := cmd.Flags().GetString("vertical-alignment"); got != "middle" {
t.Errorf("vertical-alignment = %q, want rewritten to %q", got, "middle")
}
if !*prevCalled {
t.Error("framework PreRunE must keep running first")
}
// Typo: error with suggestion, value untouched.
cmd, _ = newCmd()
cmd.Flags().Set("vertical-alignment", "botom")
err := cmd.PreRunE(cmd, nil)
var verr *errs.ValidationError
if !errors.As(err, &verr) {
t.Fatalf("typo should fail with *errs.ValidationError, got %T: %v", err, err)
}
if !strings.Contains(verr.Hint, `"bottom"`) {
t.Errorf("hint should suggest bottom for the typo, got %q", verr.Hint)
}
if got, _ := cmd.Flags().GetString("vertical-alignment"); got != "botom" {
t.Errorf("typo must not be rewritten, got %q", got)
}
// --print-schema skips enum gating (pure local introspection).
cmd, _ = newCmd()
cmd.Flags().Set("vertical-alignment", "not-a-value")
cmd.Flags().Set("print-schema", "true")
if err := cmd.PreRunE(cmd, nil); err != nil {
t.Errorf("--print-schema must skip enum gating, got: %v", err)
}
}
// shortcutFromRegistry returns the fully wired shortcut (PostMount
// ergonomics included) as Shortcuts() exposes it to the framework.
func shortcutFromRegistry(t *testing.T, command string) common.Shortcut {
t.Helper()
for _, sc := range Shortcuts() {
if sc.Command == command {
return sc
}
}
t.Fatalf("shortcut %q not found in Shortcuts()", command)
return common.Shortcut{}
}
// TestShortcuts_FlagErgonomicsMounted verifies the ergonomics ride every
// mounted sheets command end-to-end: enum vocabulary normalizes on a real
// invocation, and unknown flags answer with the inlined valid-flag list.
func TestShortcuts_FlagErgonomicsMounted(t *testing.T) {
t.Parallel()
t.Run("enum alias normalizes through a real run", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set-style")
stdout, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1:A1",
"--vertical-alignment", "center",
"--dry-run",
})
if err != nil {
t.Fatalf("center should normalize to middle and pass, got: %v", err)
}
if !strings.Contains(stdout, "middle") || strings.Contains(stdout, "center") {
t.Errorf("dry-run body should carry the normalized value, got %q", stdout)
}
})
t.Run("enum typo errors with suggestion", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cells-set-style")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--range", "A1:A1",
"--vertical-alignment", "botom",
"--dry-run",
})
ve := requireValidation(t, err, `invalid value "botom" for --vertical-alignment`)
if !strings.Contains(ve.Hint, `"bottom"`) {
t.Errorf("hint should suggest bottom, got %q", ve.Hint)
}
})
t.Run("unknown flag inlines valid flags", func(t *testing.T) {
t.Parallel()
sc := shortcutFromRegistry(t, "+cols-resize")
_, _, err := runShortcutCapturingErr(t, sc, []string{
"--url", testURL,
"--sheet-name", "s",
"--cols", "A:D",
})
ve := requireValidation(t, err, `unknown flag "--cols"`)
for _, want := range []string{"valid flags:", "--range", "--width", "--widths"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
}

View File

@@ -6,6 +6,7 @@ package sheets
import ( import (
_ "embed" _ "embed"
"encoding/json" "encoding/json"
"fmt"
"sort" "sort"
"sync" "sync"
@@ -53,7 +54,7 @@ func loadFlagSchemas() (*flagSchemaIndex, error) {
flagSchemasOnce.Do(func() { flagSchemasOnce.Do(func() {
var idx flagSchemaIndex var idx flagSchemaIndex
if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil { if err := json.Unmarshal(flagSchemasJSON, &idx); err != nil {
parseFlagErr = errs.NewInternalError(errs.SubtypeUnknown, "flag-schemas.json: %v", err).WithCause(err) parseFlagErr = fmt.Errorf("flag-schemas.json: %w", err)
return return
} }
if idx.Flags == nil { if idx.Flags == nil {

View File

@@ -5,7 +5,6 @@ package sheets
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"sort" "sort"
"strings" "strings"
@@ -98,22 +97,11 @@ func validateValueAgainstSchema(fv flagView, name string, value interface{}) err
// Composite-JSON shape errors (e.g. +cells-set --cells, chart // Composite-JSON shape errors (e.g. +cells-set --cells, chart
// --properties) are the highest-frequency usage-layer failure for // --properties) are the highest-frequency usage-layer failure for
// sheets, and agents often burn several retries guessing the shape. // sheets, and agents often burn several retries guessing the shape.
// A shallow type mismatch means the caller misremembered the overall // Point them straight at --print-schema, which dumps the exact JSON
// container shape (the classic {"cells": ...} wrapper around what // Schema for this (command, flag) pair. The hint is always actionable:
// must be a bare 2D array), so inline a skeleton of the expected // reaching this branch means entry[name] resolved a schema from the
// shape — that fixes the retry without a --print-schema round trip. // embedded index, and --print-schema reads that same index, so the
// Deeper failures keep the --print-schema pointer, which dumps the // suggested command is guaranteed to print it.
// exact JSON Schema for this (command, flag) pair; reaching this
// branch means entry[name] resolved a schema from the embedded
// index, so the suggested command is guaranteed to print it.
var tm *typeMismatchError
if errors.As(vErr, &tm) && pathDepth(tm.path) <= skeletonPathDepthLimit {
if sk := schemaSkeleton(&schema, skeletonMaxDepth); sk != "" {
return sheetsValidationForFlag(name,
"--%s: %s; expected shape: %s (run `lark-cli sheets %s --print-schema --flag-name %s` for the full JSON Schema)",
name, vErr.Error(), sk, command, name).WithCause(vErr)
}
}
return sheetsValidationForFlag(name, return sheetsValidationForFlag(name,
"--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema", "--%s: %s; run `lark-cli sheets %s --print-schema --flag-name %s` to see the expected JSON Schema",
name, vErr.Error(), command, name).WithCause(vErr) name, vErr.Error(), command, name).WithCause(vErr)
@@ -255,7 +243,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if schema.Type != "" { if schema.Type != "" {
if !matchesJSONType(value, schema.Type) { if !matchesJSONType(value, schema.Type) {
return &typeMismatchError{path: path, expected: schema.Type, got: jsType(value)} return fmt.Errorf("%sexpected type %q, got %q", pathPrefix(path), schema.Type, jsType(value))
} }
} }
@@ -263,20 +251,20 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
// already reported above). Apply to both `number` and `integer` types. // already reported above). Apply to both `number` and `integer` types.
if num, ok := value.(float64); ok { if num, ok := value.(float64); ok {
if schema.Minimum != nil && num < *schema.Minimum { if schema.Minimum != nil && num < *schema.Minimum {
return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint return fmt.Errorf("%svalue %v is below minimum %v", pathPrefix(path), num, *schema.Minimum)
} }
if schema.Maximum != nil && num > *schema.Maximum { if schema.Maximum != nil && num > *schema.Maximum {
return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint return fmt.Errorf("%svalue %v is above maximum %v", pathPrefix(path), num, *schema.Maximum)
} }
} }
// Array length bounds — only checked when value is an array. // Array length bounds — only checked when value is an array.
if arr, ok := value.([]interface{}); ok { if arr, ok := value.([]interface{}); ok {
if schema.MinItems != nil && len(arr) < *schema.MinItems { if schema.MinItems != nil && len(arr) < *schema.MinItems {
return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint return fmt.Errorf("%sarray has %d items, minimum is %d", pathPrefix(path), len(arr), *schema.MinItems)
} }
if schema.MaxItems != nil && len(arr) > *schema.MaxItems { if schema.MaxItems != nil && len(arr) > *schema.MaxItems {
return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint return fmt.Errorf("%sarray has %d items, maximum is %d", pathPrefix(path), len(arr), *schema.MaxItems)
} }
} }
@@ -291,10 +279,10 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if !matched { if !matched {
msg := fmt.Sprintf("%svalue %s is not in enum %s", msg := fmt.Sprintf("%svalue %s is not in enum %s",
pathPrefix(path), formatJSONValue(value), formatEnum(schema.Enum)) pathPrefix(path), formatJSONValue(value), formatEnum(schema.Enum))
if hint := suggestEnumForError(value, schema.Enum); hint != "" { if hint := suggestEnumMatch(value, schema.Enum); hint != "" {
msg += fmt.Sprintf(` (did you mean %q?)`, hint) msg += fmt.Sprintf(` (did you mean %q?)`, hint)
} }
return fmt.Errorf("%s", msg) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint return fmt.Errorf("%s", msg)
} }
} }
@@ -307,7 +295,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
} }
} }
if !matched { if !matched {
return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint return fmt.Errorf("%svalue does not match any of oneOf alternatives", pathPrefix(path))
} }
} }
@@ -317,7 +305,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
if obj, ok := value.(map[string]interface{}); ok { if obj, ok := value.(map[string]interface{}); ok {
for _, key := range schema.Required { for _, key := range schema.Required {
if _, present := obj[key]; !present { if _, present := obj[key]; !present {
return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path)) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint return fmt.Errorf("required property %q is missing at %s", key, pathOrRoot(path))
} }
} }
if schema.Properties != nil { if schema.Properties != nil {
@@ -369,7 +357,7 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
sort.Strings(extras) sort.Strings(extras)
for _, key := range extras { for _, key := range extras {
if schema.AdditionalProperties.Strict { if schema.AdditionalProperties.Strict {
return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key) //nolint:forbidigo // intermediate error; validateFlagAgainstSchema wraps it into a typed flag validation error with a --print-schema hint return fmt.Errorf("%sunexpected property %q (not declared in schema)", pathPrefix(path), key)
} }
if schema.AdditionalProperties.Schema != nil { if schema.AdditionalProperties.Schema != nil {
child := key child := key
@@ -400,126 +388,6 @@ func validateAgainstSchema(value interface{}, schema *schemaProperty, path strin
return nil return nil
} }
// typeMismatchError is the type-check branch of validateAgainstSchema
// as a typed error, so validateValueAgainstSchema can recognize shape
// confusion (vs. deep value errors) and inline a skeleton of the
// expected shape. Error() keeps the exact legacy wording.
type typeMismatchError struct {
path string
expected string
got string
}
func (e *typeMismatchError) Error() string {
return fmt.Sprintf("%sexpected type %q, got %q", pathPrefix(e.path), e.expected, e.got)
}
// pathDepth counts how many levels below the flag root a JSON path
// points at: "" → 0, "[0]" → 1, "[0][3]" → 2, "[0][3].value" → 3,
// "legend" → 1, "snapshot.axes" → 2. Every "[" and "." starts a new
// segment; a leading bare key (no bracket) is one segment of its own.
func pathDepth(path string) int {
depth := strings.Count(path, "[") + strings.Count(path, ".")
if path != "" && path[0] != '[' {
depth++
}
return depth
}
// Skeleton rendering bounds: a mismatch at depth ≤ 2 is container-shape
// confusion worth a skeleton; deeper mismatches are value-level and the
// full schema pointer serves better. The skeleton itself stops after
// four levels and eight keys per object so it stays one line; a wide
// object (> skeletonWideObject keys) collapses its children to type
// placeholders so every key stays visible instead of the first branch
// eating the whole line.
const (
skeletonPathDepthLimit = 2
skeletonMaxDepth = 4
skeletonMaxKeys = 8
skeletonWideObject = 2
)
// schemaSkeleton renders a compact single-line sketch of the shape a
// schema expects, e.g. [[{"value": …, "formula": "…", …}]] for
// +cells-set --cells. Required keys come first, then alphabetical,
// capped at skeletonMaxKeys with a trailing … marker. Values render as
// their type placeholder; enum strings show the first allowed value.
func schemaSkeleton(s *schemaProperty, depth int) string {
if s == nil {
return "…"
}
if len(s.OneOf) > 0 && s.Type == "" {
return schemaSkeleton(s.OneOf[0], depth)
}
switch s.Type {
case "array":
if depth <= 0 {
return "[…]"
}
return "[" + schemaSkeleton(s.Items, depth-1) + "]"
case "object":
if depth <= 0 || len(s.Properties) == 0 {
return "{…}"
}
keys := skeletonKeys(s)
childDepth := depth - 1
if len(s.Properties) > skeletonWideObject {
childDepth = 0
}
parts := make([]string, 0, len(keys)+1)
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%q: %s", k, schemaSkeleton(s.Properties[k], childDepth)))
}
if len(s.Properties) > len(keys) {
parts = append(parts, "…")
}
return "{" + strings.Join(parts, ", ") + "}"
case "string":
if len(s.Enum) > 0 {
return formatJSONValue(s.Enum[0])
}
return `"…"`
case "number", "integer":
return "0"
case "boolean":
return "false"
}
return "…"
}
// skeletonKeys picks which object keys a skeleton shows: required keys
// first (schema order), then remaining keys alphabetically, capped at
// skeletonMaxKeys.
func skeletonKeys(s *schemaProperty) []string {
keys := make([]string, 0, skeletonMaxKeys)
seen := make(map[string]struct{}, skeletonMaxKeys)
for _, k := range s.Required {
if _, ok := s.Properties[k]; !ok {
continue
}
if len(keys) == skeletonMaxKeys {
return keys
}
keys = append(keys, k)
seen[k] = struct{}{}
}
rest := make([]string, 0, len(s.Properties))
for k := range s.Properties {
if _, dup := seen[k]; !dup {
rest = append(rest, k)
}
}
sort.Strings(rest)
for _, k := range rest {
if len(keys) == skeletonMaxKeys {
break
}
keys = append(keys, k)
}
return keys
}
func matchesJSONType(value interface{}, expected string) bool { func matchesJSONType(value interface{}, expected string) bool {
switch expected { switch expected {
case "object": case "object":
@@ -605,48 +473,25 @@ func joinFormatted(values []interface{}) string {
return strings.Join(parts, ", ") return strings.Join(parts, ", ")
} }
// suggestEnumMatch returns the canonical enum entry when the user's // suggestEnumMatch returns a "did you mean" candidate when the user's
// value unambiguously means one — casing ("SUM" vs "sum", "True" vs // value differs from an allowed enum entry only in casing — the most
// "true") or a cross-vocabulary alias (CSS "center" for Lark's vertical // common real-world mistake ("SUM" vs "sum", "True" vs "true"). The
// "middle"). Callers auto-apply the result, so it must stay restricted // match is restricted to strings; non-string enums (numbers, etc.)
// to unambiguous matches (edit-distance guesses belong in // don't have a casing notion. Returns "" when no near-miss exists.
// suggestEnumForError only). Non-string values have no vocabulary
// notion. Returns "" when no unambiguous match exists.
func suggestEnumMatch(value interface{}, values []interface{}) string { func suggestEnumMatch(value interface{}, values []interface{}) string {
s, ok := value.(string) s, ok := value.(string)
if !ok { if !ok {
return "" return ""
} }
canon := canonicalEnumValue(s, stringEnumEntries(values)) lower := strings.ToLower(s)
if canon == "" || canon == s { // skip exact-equal (already would have matched).
return ""
}
return canon
}
// stringEnumEntries extracts the string members of a JSON-schema enum
// list (mixed-type enums keep only their string entries).
func stringEnumEntries(values []interface{}) []string {
out := make([]string, 0, len(values))
for _, v := range values { for _, v := range values {
if vs, ok := v.(string); ok { if vs, ok := v.(string); ok && strings.ToLower(vs) == lower {
out = append(out, vs) if vs != s { // skip exact-equal (already would have matched).
return vs
}
} }
} }
return out return ""
}
// suggestEnumForError picks the "did you mean" candidate for an enum
// error message. Unlike suggestEnumMatch (whose result is auto-applied,
// so it must stay unambiguous), this one may also draw on edit distance
// — the suggestion is only prose, the user still has to re-issue the
// value explicitly.
func suggestEnumForError(value interface{}, values []interface{}) string {
s, ok := value.(string)
if !ok {
return ""
}
return closestEnumValue(s, stringEnumEntries(values))
} }
func pathPrefix(path string) string { func pathPrefix(path string) string {

View File

@@ -360,142 +360,6 @@ func TestValidateInputAgainstSchema_RealEnumCaseNormalized(t *testing.T) {
} }
} }
// TestValidateAgainstSchema_EnumAliasNormalized pins the cross-vocabulary
// auto-fix: CSS-habit "center" for a vertical alignment unambiguously means
// Lark's "middle", so the payload is normalized in place and the call
// proceeds — same treatment as the "SUM" vs "sum" casing class.
func TestValidateAgainstSchema_EnumAliasNormalized(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"object",
"properties":{"vertical_alignment":{"type":"string","enum":["top","middle","bottom"]}}
}`)
obj := map[string]interface{}{"vertical_alignment": "center"}
if err := validateAgainstSchema(obj, schema, ""); err != nil {
t.Fatalf("center should normalize to middle and pass, got: %v", err)
}
if got := obj["vertical_alignment"]; got != "middle" {
t.Errorf("vertical_alignment = %q, want normalized to %q", got, "middle")
}
}
// TestValidateAgainstSchema_EnumTypoSuggestedNotApplied pins the auto-apply
// boundary on the error path: an edit-distance typo stays an error with a
// "did you mean" suggestion, never a silent rewrite.
func TestValidateAgainstSchema_EnumTypoSuggestedNotApplied(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"object",
"properties":{"order":{"type":"string","enum":["asc","desc"]}}
}`)
obj := map[string]interface{}{"order": "ascc"}
err := validateAgainstSchema(obj, schema, "")
if err == nil {
t.Fatal("typo must be rejected")
}
if !strings.Contains(err.Error(), `did you mean "asc"?`) {
t.Errorf("enum error should suggest asc for the typo, got %q", err.Error())
}
if got := obj["order"]; got != "ascc" {
t.Errorf("typo must not be rewritten, got %q", got)
}
}
// TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch
// pins the highest-frequency eval failure: passing an object where
// --cells expects a 2D array must inline a skeleton of the expected
// shape (with the "value" key visible) so an agent fixes the retry
// without a --print-schema round trip.
func TestValidateValueAgainstSchema_ShapeSkeletonOnShallowTypeMismatch(t *testing.T) {
t.Parallel()
fv := mapFlagView{command: "+cells-set"}
err := validateValueAgainstSchema(fv, "cells",
map[string]interface{}{"cells": []interface{}{}})
if err == nil {
t.Fatal("object where array expected must fail")
}
msg := err.Error()
for _, want := range []string{`expected type "array", got "object"`, "expected shape: [[{", `"value"`} {
if !strings.Contains(msg, want) {
t.Errorf("error should contain %q, got %q", want, msg)
}
}
// Deep value-level mismatch keeps the plain --print-schema pointer
// (a whole-shape skeleton would not address the actual problem).
deep := []interface{}{[]interface{}{
map[string]interface{}{"note": 12.5},
}}
err = validateValueAgainstSchema(fv, "cells", deep)
if err == nil {
t.Fatal("wrong type for note must fail")
}
if strings.Contains(err.Error(), "expected shape:") {
t.Errorf("deep mismatch should not inline a skeleton, got %q", err.Error())
}
if !strings.Contains(err.Error(), "--print-schema") {
t.Errorf("deep mismatch should keep the --print-schema pointer, got %q", err.Error())
}
}
func TestPathDepth(t *testing.T) {
t.Parallel()
cases := []struct {
path string
want int
}{
{"", 0},
{"[0]", 1},
{"[0][3]", 2},
{"[0][3].value", 3},
{"legend", 1},
{"snapshot.axes", 2},
}
for _, c := range cases {
if got := pathDepth(c.path); got != c.want {
t.Errorf("pathDepth(%q) = %d, want %d", c.path, got, c.want)
}
}
}
func TestSchemaSkeleton(t *testing.T) {
t.Parallel()
schema := parseSchema(t, `{
"type":"array",
"items":{
"type":"array",
"items":{
"type":"object",
"properties":{
"value":{},
"formula":{"type":"string"},
"align":{"type":"string","enum":["top","middle","bottom"]},
"styles":{"type":"object","properties":{"bold":{"type":"boolean"}}}
}
}
}
}`)
got := schemaSkeleton(schema, skeletonMaxDepth)
// Wide object (>2 keys) collapses nested containers to placeholders;
// enum strings surface their first allowed value.
want := `[[{"align": "top", "formula": "…", "styles": {…}, "value": …}]]`
if got != want {
t.Errorf("skeleton = %s, want %s", got, want)
}
narrow := parseSchema(t, `{
"type":"object",
"required":["sheets"],
"properties":{"sheets":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"}}}}}
}`)
got = schemaSkeleton(narrow, skeletonMaxDepth)
// Narrow object (≤2 keys) keeps descending so the inner shape shows.
want = `{"sheets": [{"name": "…"}]}`
if got != want {
t.Errorf("narrow skeleton = %s, want %s", got, want)
}
}
// TestValidateAgainstSchema_NilSchemaSafe pins the defensive // TestValidateAgainstSchema_NilSchemaSafe pins the defensive
// `if schema == nil { return nil }` guard. Current production callers // `if schema == nil { return nil }` guard. Current production callers
// always hand validator a real schema, but the guard means future // always hand validator a real schema, but the guard means future

View File

@@ -19,7 +19,6 @@ var commandsWithSchema = map[string]struct{}{
"+cells-set-style": {}, "+cells-set-style": {},
"+chart-create": {}, "+chart-create": {},
"+chart-update": {}, "+chart-update": {},
"+cols-resize": {},
"+cond-format-create": {}, "+cond-format-create": {},
"+cond-format-update": {}, "+cond-format-update": {},
"+dropdown-set": {}, "+dropdown-set": {},
@@ -31,7 +30,6 @@ var commandsWithSchema = map[string]struct{}{
"+pivot-create": {}, "+pivot-create": {},
"+pivot-update": {}, "+pivot-update": {},
"+range-sort": {}, "+range-sort": {},
"+rows-resize": {},
"+sparkline-create": {}, "+sparkline-create": {},
"+sparkline-update": {}, "+sparkline-update": {},
"+table-put": {}, "+table-put": {},

View File

@@ -281,18 +281,18 @@ func (m mapFlagView) validateRawTypes() error {
// parse time; reject here too to keep batch/standalone parity. // parse time; reject here too to keep batch/standalone parity.
f, isNum := val.(float64) f, isNum := val.(float64)
if !isNum { if !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
} }
if math.Trunc(f) != f { if math.Trunc(f) != f {
return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error return fmt.Errorf("--%s must be an integer, got %s", name, strconv.FormatFloat(f, 'g', -1, 64))
} }
case "float64": case "float64":
if _, isNum := val.(float64); !isNum { if _, isNum := val.(float64); !isNum {
return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error return fmt.Errorf("--%s must be a number, got %s", name, jsonTypeName(val))
} }
case "bool": case "bool":
if _, isBool := val.(bool); !isBool { if _, isBool := val.(bool); !isBool {
return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val)) //nolint:forbidigo // intermediate error; the batch dispatcher wraps it into a typed operations validation error return fmt.Errorf("--%s must be a boolean, got %s", name, jsonTypeName(val))
} }
} }
} }

View File

@@ -10,7 +10,6 @@ package sheets
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
neturl "net/url" neturl "net/url"
"strings" "strings"
@@ -45,8 +44,7 @@ func sheetsValidationCauseForFlag(name string, cause error) *errs.ValidationErro
// classification and only adds the domain's flag param. // classification and only adds the domain's flag param.
func sheetsInputStatError(flag string, err error) error { func sheetsInputStatError(flag string, err error) error {
wrapped := common.WrapInputStatErrorTyped(err) wrapped := common.WrapInputStatErrorTyped(err)
var v *errs.ValidationError if v, ok := wrapped.(*errs.ValidationError); ok {
if errors.As(wrapped, &v) {
return v.WithParam(sheetsFlagParam(flag)) return v.WithParam(sheetsFlagParam(flag))
} }
return wrapped return wrapped
@@ -54,30 +52,21 @@ func sheetsInputStatError(flag string, err error) error {
// Drive media parent_type values for uploading an image into a spreadsheet. // Drive media parent_type values for uploading an image into a spreadsheet.
// Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a // Native spreadsheets use "sheet_image"; imported "office" spreadsheets carry a
// synthetic token prefixed with "fake_office_" (being renamed to // synthetic token prefixed with "fake_office_" and the backend requires
// "local_office_") and the backend requires "office_sheet_file" instead. // "office_sheet_file" instead.
const ( const (
sheetImageParentType = "sheet_image" sheetImageParentType = "sheet_image"
officeSheetFileParentType = "office_sheet_file" officeSheetFileParentType = "office_sheet_file"
fakeOfficeTokenPrefix = "fake_office_" fakeOfficeTokenPrefix = "fake_office_"
localOfficeTokenPrefix = "local_office_"
) )
// officeTokenPrefixes are the synthetic token prefixes an imported "office"
// spreadsheet may carry. The prefix is being renamed from "fake_office_" to
// "local_office_"; accept either so image uploads keep working across the
// rename.
var officeTokenPrefixes = []string{fakeOfficeTokenPrefix, localOfficeTokenPrefix}
// sheetMediaParentType returns the drive media parent_type to use when // sheetMediaParentType returns the drive media parent_type to use when
// uploading an image whose parent_node is spreadsheetToken. It is the single // uploading an image whose parent_node is spreadsheetToken. It is the single
// place that maps a spreadsheet token to its parent_type so every image-upload // place that maps a spreadsheet token to its parent_type so every image-upload
// entry point (and its dry-run preview) stays consistent. // entry point (and its dry-run preview) stays consistent.
func sheetMediaParentType(spreadsheetToken string) string { func sheetMediaParentType(spreadsheetToken string) string {
for _, prefix := range officeTokenPrefixes { if strings.HasPrefix(spreadsheetToken, fakeOfficeTokenPrefix) {
if strings.HasPrefix(spreadsheetToken, prefix) { return officeSheetFileParentType
return officeSheetFileParentType
}
} }
return sheetImageParentType return sheetImageParentType
} }
@@ -451,7 +440,7 @@ func requireJSONArray(runtime flagView, name string) ([]interface{}, error) {
// ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─ // ─── style flags (shared by +cells-set-style and +cells-batch-set-style) ─
// buildCellStyleFromFlags reads the 12 flat style flags and returns the // buildCellStyleFromFlags reads the 11 flat style flags and returns the
// cell_styles map expected by set_cell_range. Skips any flag the user // cell_styles map expected by set_cell_range. Skips any flag the user
// didn't set so partial styles work. // didn't set so partial styles work.
func buildCellStyleFromFlags(runtime flagView) map[string]interface{} { func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
@@ -462,9 +451,6 @@ func buildCellStyleFromFlags(runtime flagView) map[string]interface{} {
if v := runtime.Str("font-color"); v != "" { if v := runtime.Str("font-color"); v != "" {
style["font_color"] = v style["font_color"] = v
} }
if v := runtime.Str("font-family"); v != "" {
style["font_family"] = v
}
if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 { if runtime.Changed("font-size") && runtime.Float64("font-size") > 0 {
style["font_size"] = runtime.Float64("font-size") style["font_size"] = runtime.Float64("font-size")
} }

View File

@@ -215,8 +215,7 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
if borderStyles != nil { if borderStyles != nil {
prototype["border_styles"] = borderStyles prototype["border_styles"] = borderStyles
} }
ops := make([]interface{}, 0, len(ranges)) var ops []interface{}
var totalCells int64
for _, rng := range ranges { for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng) sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil { if err != nil {
@@ -226,13 +225,6 @@ func cellsBatchSetStyleInput(runtime *common.RuntimeContext, token string) (map[
if err != nil { if err != nil {
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err) return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
} }
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
return nil, err
}
totalCells += int64(rows) * int64(cols)
if err := checkBatchStampBudget(totalCells); err != nil {
return nil, err
}
cells := fillCellsMatrix(rows, cols, prototype) cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{ ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range", "tool_name": "set_cell_range",
@@ -307,7 +299,7 @@ func cellsBatchClearInput(runtime *common.RuntimeContext, token string) (map[str
return nil, err return nil, err
} }
clearType := normalizeClearType(runtime.Str("scope")) clearType := normalizeClearType(runtime.Str("scope"))
ops := make([]interface{}, 0, len(ranges)) var ops []interface{}
for _, rng := range ranges { for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng) sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil { if err != nil {
@@ -390,10 +382,13 @@ var DropdownDelete = common.Shortcut{
if _, err := resolveSpreadsheetToken(runtime); err != nil { if _, err := resolveSpreadsheetToken(runtime); err != nil {
return err return err
} }
// validateDropdownRanges enforces the shared maxBatchRanges cap. ranges, err := validateDropdownRanges(runtime)
if _, err := validateDropdownRanges(runtime); err != nil { if err != nil {
return err return err
} }
if len(ranges) > 100 {
return sheetsValidationForFlag("ranges", "--ranges accepts at most 100 entries; got %d", len(ranges))
}
return nil return nil
}, },
DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI { DryRun: func(ctx context.Context, runtime *common.RuntimeContext) *common.DryRunAPI {
@@ -437,8 +432,7 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
} }
prototype = map[string]interface{}{"data_validation": validation} prototype = map[string]interface{}{"data_validation": validation}
} }
ops := make([]interface{}, 0, len(ranges)) var ops []interface{}
var totalCells int64
for _, rng := range ranges { for _, rng := range ranges {
sheet, sub, err := splitSheetPrefixedRange(rng) sheet, sub, err := splitSheetPrefixedRange(rng)
if err != nil { if err != nil {
@@ -448,13 +442,6 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
if err != nil { if err != nil {
return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err) return nil, sheetsValidationForFlag("range", "range %q: %v", rng, err)
} }
if err := checkStampMatrixBudget("ranges", rng, rows, cols); err != nil {
return nil, err
}
totalCells += int64(rows) * int64(cols)
if err := checkBatchStampBudget(totalCells); err != nil {
return nil, err
}
cells := fillCellsMatrix(rows, cols, prototype) cells := fillCellsMatrix(rows, cols, prototype)
ops = append(ops, map[string]interface{}{ ops = append(ops, map[string]interface{}{
"tool_name": "set_cell_range", "tool_name": "set_cell_range",
@@ -474,25 +461,6 @@ func dropdownBatchInput(runtime *common.RuntimeContext, token string, clear bool
// ─── helpers resurrected from B3 (used here + future skills) ────────── // ─── helpers resurrected from B3 (used here + future skills) ──────────
// maxBatchRanges caps how many ranges a fan-out batch (+cells-batch-set-style /
// +cells-batch-clear / +dropdown-update / +dropdown-delete) may carry, bounding
// the number of ops materialized into one batch_update.
const maxBatchRanges = 100
// checkBatchStampBudget rejects a fan-out batch whose ranges materialize more
// than maxStampMatrixCells cells in aggregate. A batch builds every range's
// cells matrix up front, so the SUM across ranges is the real peak-memory bound
// — the per-range checkStampMatrixBudget alone can't stop many ranges from
// summing past it. totalCells is int64 to stay overflow-safe.
func checkBatchStampBudget(totalCells int64) error {
if totalCells > maxStampMatrixCells {
return sheetsValidationForFlag("ranges",
"ranges expand to %d cells total, over the %d-cell safety cap; reduce the number or size of ranges",
totalCells, maxStampMatrixCells)
}
return nil
}
// validateDropdownRanges parses --ranges, requires every entry to carry a // validateDropdownRanges parses --ranges, requires every entry to carry a
// sheet prefix, and returns the parsed list. // sheet prefix, and returns the parsed list.
func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) { func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
@@ -522,9 +490,6 @@ func validateDropdownRanges(runtime *common.RuntimeContext) ([]string, error) {
} }
out = append(out, s) out = append(out, s)
} }
if len(out) > maxBatchRanges {
return nil, sheetsValidationForFlag("ranges", "--ranges accepts at most %d entries; got %d", maxBatchRanges, len(out))
}
return out, nil return out, nil
} }

View File

@@ -5,7 +5,6 @@ package sheets
import ( import (
"encoding/json" "encoding/json"
"strings"
"testing" "testing"
) )
@@ -420,94 +419,6 @@ func TestBatchUpdate_TranslatorRejects(t *testing.T) {
} }
} }
// TestBatchUpdate_PrescriptiveHints pins the recovery hints that ride on the
// highest-frequency batch failures, so an agent can repair its payload in a
// single retry without --help / --print-schema round trips.
func TestBatchUpdate_PrescriptiveHints(t *testing.T) {
t.Parallel()
cases := []struct {
name string
opsJSON string
wantMatch string
wantInHint []string
}{
{
name: "missing shortcut gets entry template",
opsJSON: `[{"input":{"range":"A1"}}]`,
wantMatch: "'shortcut' field is required",
wantInHint: []string{`{"shortcut":"+cells-set"`, `"input"`},
},
{
name: "disallowed shortcut lists the allow-list inline",
opsJSON: `[{"shortcut":"+cells-batch-set-style","input":{}}]`,
wantMatch: "not allowed in +batch-update",
wantInHint: []string{"allowed shortcuts:", "+cells-set-style", "+range-copy"},
},
{
name: "translator failure lists full key contract",
opsJSON: `[{"shortcut":"+dim-insert","input":{"sheet_name":"s"}}]`,
wantMatch: "--position is required",
wantInHint: []string{"+dim-insert input keys:", "sheet_id|sheet_name (choose one)", "position (required)", "count (required)", "inherit_style"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
_, _, err := runShortcutCapturingErr(t, BatchUpdate, []string{
"--url", testURL,
"--operations", tc.opsJSON,
"--yes",
"--dry-run",
})
ve := requireValidation(t, err, tc.wantMatch)
for _, want := range tc.wantInHint {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
})
}
}
// TestTranslateBatchOperations_OverLimitSplitHint pins the split
// prescription on the 100-entry cap: the hint must say how many batches
// the caller should re-issue.
func TestTranslateBatchOperations_OverLimitSplitHint(t *testing.T) {
t.Parallel()
ops := make([]interface{}, 185)
for i := range ops {
ops[i] = map[string]interface{}{"shortcut": "+cells-set", "input": map[string]interface{}{}}
}
_, err := translateBatchOperations(ops, "shtcnX")
ve := requireValidation(t, err, "accepts at most 100 entries; got 185")
for _, want := range []string{"2 separate +batch-update calls", "at most 100 entries each"} {
if !strings.Contains(ve.Hint, want) {
t.Errorf("hint should contain %q, got %q", want, ve.Hint)
}
}
}
// TestSubOpInputContract pins the contract line derivation from flag-defs:
// reserved spreadsheet locators are omitted, the sheet selector collapses
// to a choose-one, and required flags are marked.
func TestSubOpInputContract(t *testing.T) {
t.Parallel()
got := subOpInputContract("+dim-insert")
for _, want := range []string{"sheet_id|sheet_name (choose one)", "position (required)", "count (required)", "inherit_style"} {
if !strings.Contains(got, want) {
t.Errorf("contract should contain %q, got %q", want, got)
}
}
for _, banned := range []string{"url", "spreadsheet_token", "dry_run"} {
if strings.Contains(got, banned) {
t.Errorf("contract must not expose %q, got %q", banned, got)
}
}
if got := subOpInputContract("+no-such-shortcut"); got != "" {
t.Errorf("unknown shortcut should yield empty contract, got %q", got)
}
}
// TestBatchUpdate_DimFreezeInjectsFreeze covers the static-freeze-only // TestBatchUpdate_DimFreezeInjectsFreeze covers the static-freeze-only
// path: +dim-freeze always injects operation=freeze (count==0 unfreeze // path: +dim-freeze always injects operation=freeze (count==0 unfreeze
// path of the single shortcut is intentionally not supported in batch). // path of the single shortcut is intentionally not supported in batch).
@@ -536,7 +447,7 @@ func TestBatchUpdate_ResizeNoOperationField(t *testing.T) {
t.Parallel() t.Parallel()
body := parseDryRunBody(t, BatchUpdate, []string{ body := parseDryRunBody(t, BatchUpdate, []string{
"--url", testURL, "--url", testURL,
"--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","height":30}}]`, "--operations", `[{"shortcut":"+rows-resize","input":{"sheet_id":"sh1","range":"1:3","type":"pixel","size":30}}]`,
"--yes", "--yes",
}) })
input := decodeToolInput(t, body, "batch_update") input := decodeToolInput(t, body, "batch_update")

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